From 30ca137d6d91e01b70a256e88e91b547dba81fb5 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Wed, 19 Aug 2026 15:58:57 +0530 Subject: [PATCH 01/31] Add the capability registry tool surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five tools driven by a prebuilt index artifact: listProducts, listEntities, describeEntity, searchCapability, invokeEndpoint. 173 tm endpoints reachable from one generic invoke tool instead of a hand-written tool per operation. WHY AN INDEX AND NOT THE SPECS. The artifact (193KB, vs ~1.6MB for the three raw harness bundles) is generated by the Python side and contains only data that has already passed its outbound boundary. Had this half parsed openapi.yaml it would BECOME the boundary, and every gate — route lint, the positive vocabulary rule, the intent lint, the discovery denylist — would have to be reimplemented and re-tested here, with a subtle mistake publishing what the other side withholds and no test to catch it. An index cannot leak because the internal data is not in it: a test asserts x-atlas-permission, parameter `target` names, body `pointer`s, raw key_facts, entity `operations[]`, page/count params and strip_prefix are all absent. No hostname is baked in either, so one artifact ships to every environment. Endpoints are the handle, not capability names, and arguments arrive grouped into path_params / query / body under the spec's OWN names — so a caller passes search output straight back with no remapping. Grouping is not cosmetic: four tm operations declare one name in two places (bulk-move has folder_id as both a path parameter and a body field), which a flat map cannot express. Body fields carry json_path when the nesting differs from the field name, because that nesting is unguessable and fails silently — tm's folder create wants {folder: {name}} while the flat {name} a reader would assume returns 200 and discards the field. ONE invoke tool means one set of MCP annotations, so they describe the whole surface honestly: readOnlyHint false (it can write), destructiveHint false (it can never delete, because destructive endpoints are refused before binding). Write consent therefore rests on user_permission enforced here rather than on a client hint. Parameters are validated BEFORE permission is demanded, so a typo cannot send someone to ask a human about a call that was never going to run. Auth forwards the caller's own credentials as `Api-Token: :`, which every /api/v1 route validates against IAAM OAuth2 v2. HTTP Basic is NOT usable there — authenticate_with_authorization_header never reaches the Basic path — so Api-Token is the scheme that covers the whole surface, with no token to mint, nothing to refresh mid-pagination, and no auth-host/environment coupling. Ported faithfully from the Python resolver, including the rules each learned from a live failure: rows found by SHAPE (tm uses 30 distinct row-key names; a hardcoded list of 13 missed 24 of them), a single wrapped record counted as one row (otherwise every stats/summary read returned ok:true count:0), an empty array kept distinguishable from a shape with no rows, paging at the declared ceiling (880 projects walked 30 at a time was the 17 Aug incident), search penalties that reorder without excluding (conflating them once dropped 40 valid matches), and discovery mode publishing scalars only minus a sensitive-name denylist. 35 new tests; the full suite is 311 across 31 files. Not yet wired into server-factory.ts — registration is opt-in until the overlap with the 17 hand-written testmanagement.ts tools is settled. --- src/tools/capability-registry/bind.ts | 152 ++++++++++ src/tools/capability-registry/egress.ts | 87 ++++++ src/tools/capability-registry/envelope.ts | 147 ++++++++++ src/tools/capability-registry/index-loader.ts | 92 ++++++ src/tools/capability-registry/register.ts | 190 ++++++++++++ src/tools/capability-registry/resolve.ts | 139 +++++++++ src/tools/capability-registry/search.ts | 154 ++++++++++ src/tools/capability-registry/types.ts | 76 +++++ tests/fixtures/registry-index.json | 1 + tests/tools/capabilityRegistry.test.ts | 270 ++++++++++++++++++ .../tools/capabilityRegistryArtifact.test.ts | 62 ++++ 11 files changed, 1370 insertions(+) create mode 100644 src/tools/capability-registry/bind.ts create mode 100644 src/tools/capability-registry/egress.ts create mode 100644 src/tools/capability-registry/envelope.ts create mode 100644 src/tools/capability-registry/index-loader.ts create mode 100644 src/tools/capability-registry/register.ts create mode 100644 src/tools/capability-registry/resolve.ts create mode 100644 src/tools/capability-registry/search.ts create mode 100644 src/tools/capability-registry/types.ts create mode 100644 tests/fixtures/registry-index.json create mode 100644 tests/tools/capabilityRegistry.test.ts create mode 100644 tests/tools/capabilityRegistryArtifact.test.ts diff --git a/src/tools/capability-registry/bind.ts b/src/tools/capability-registry/bind.ts new file mode 100644 index 0000000..1204542 --- /dev/null +++ b/src/tools/capability-registry/bind.ts @@ -0,0 +1,152 @@ +/** + * Turn grouped caller arguments into a path, a query and a body. + * + * Arguments arrive GROUPED — {path_params, query, body} — because spec parameter names + * collide across locations: four tm operations declare one name in two places (`bulk-move` + * has `folder_id` as both a path parameter and a body field). A flat map cannot say which + * one is meant, which is exactly why the Python side used to rename body fields `body_*`. + * Grouping removes the collision AND the rename, so a caller sends the spec's own names. + */ + +import { InvocationError } from "./index-loader.js"; +import { Capability, WireParam } from "./types.js"; + +export interface GroupedArguments { + path_params?: Record; + query?: Record; + body?: Record; +} + +export interface BoundRequest { + path: string; + query: Record; + body?: Record; +} + +/** + * Check one argument against its declared schema, raising a caller-safe error. + * + * Type checking is also the injection defence for path parameters: most of tm's 278 path + * parameters are `type: integer`, so a traversal attempt like `../../admin-v2` fails here + * rather than being encoded into a URL. + */ +export function coerce(value: unknown, param: WireParam): unknown { + const expected = param.type; + 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. + if (expected === "object" && (typeof value !== "object" || value === null || Array.isArray(value))) { + throw new InvocationError(`'${param.name}' must be an object`); + } + if (expected === "array" && !Array.isArray(value)) { + throw new InvocationError(`'${param.name}' must be a list`); + } + return value; + } + if (expected === "integer" || expected === "number") { + const parsed = Number(String(value).trim()); + if (!Number.isFinite(parsed)) { + throw new InvocationError(`'${param.name}' must be a number`); + } + return expected === "integer" ? Math.trunc(parsed) : parsed; + } + if (expected === "boolean") { + if (typeof value === "boolean") return value; + const text = String(value).trim().toLowerCase(); + if (["true", "1", "yes"].includes(text)) return true; + if (["false", "0", "no"].includes(text)) return false; + throw new InvocationError(`'${param.name}' must be true or false`); + } + const text = String(value); + if (param.values && param.values.length > 0) { + const allowed = param.values.map((v) => String(v)); + if (!allowed.includes(text)) { + throw new InvocationError(`'${param.name}' must be one of: ${allowed.join(", ")}`); + } + } + return text; +} + +/** Place a value at a JSON-pointer-ish path, creating the objects on the way. */ +function place(root: Record, pointer: string, value: unknown): void { + const segments = pointer.split("/").filter((segment) => segment !== ""); + let cursor = root; + for (const segment of segments.slice(0, -1)) { + const next = cursor[segment]; + if (typeof next !== "object" || next === null || Array.isArray(next)) { + cursor[segment] = {}; + } + cursor = cursor[segment] as Record; + } + cursor[segments[segments.length - 1]] = value; +} + +const GROUPS: { group: keyof GroupedArguments; declared: keyof Capability }[] = [ + { group: "path_params", declared: "path_params" }, + { group: "query", declared: "query" }, + { group: "body", declared: "body" }, +]; + +export function bind(capability: Capability, args: GroupedArguments): BoundRequest { + let path = capability.path; + const query: Record = {}; + const body: Record = {}; + + for (const { group, declared } of GROUPS) { + const supplied = args[group] || {}; + if (typeof supplied !== "object" || supplied === null || Array.isArray(supplied)) { + throw new InvocationError(`${group} must be an object of name -> value`); + } + const params = (capability[declared] as WireParam[] | undefined) || []; + const byName = new Map(params.map((param) => [param.name, param])); + + // Unknown arguments are an error rather than being dropped: silently ignoring a + // misspelled filter would return a larger result set that looks like a correct answer. + const unknown = Object.keys(supplied).filter((name) => !byName.has(name)); + if (unknown.length > 0) { + throw new InvocationError( + `unknown ${group}: ${unknown.sort().join(", ")}. accepted: ` + + `${[...byName.keys()].sort().join(", ") || "none"}`, + ); + } + + for (const [name, raw] of Object.entries(supplied)) { + const param = byName.get(name)!; + const value = coerce(raw, param); + if (group === "path_params") { + // Encode with nothing exempt: a `/` inside a path value would otherwise rewrite the + // route. Schema checking already stops this for integer ids; this covers strings. + path = path.replaceAll(`{${name}}`, encodeURIComponent(String(value))); + } else if (group === "body") { + place(body, param.json_path || `/${name}`, value); + } else { + query[name] = value; + } + } + } + + // `required` is enforced for BODY as well as path. It was path-only on the Python side at + // first, so a missing required body field passed silently and the product answered with a + // 4xx that read like the caller's fault. + const missing: string[] = []; + for (const { group, declared } of GROUPS) { + if (group === "query") continue; + const supplied = args[group] || {}; + for (const param of (capability[declared] as WireParam[] | undefined) || []) { + if (param.required && !(param.name in supplied)) missing.push(param.name); + } + } + if (missing.length > 0) { + throw new InvocationError( + `missing required parameter(s): ${missing.sort().join(", ")}`, + ); + } + + const leftover = path.match(/\{[a-z_]+\}/gi); + if (leftover) { + throw new InvocationError( + `path placeholder(s) not supplied: ${leftover.join(", ")}`, + ); + } + return { path, query, body: Object.keys(body).length > 0 ? body : undefined }; +} diff --git a/src/tools/capability-registry/egress.ts b/src/tools/capability-registry/egress.ts new file mode 100644 index 0000000..f92531c --- /dev/null +++ b/src/tools/capability-registry/egress.ts @@ -0,0 +1,87 @@ +/** + * The outbound call: auth, attribution, and one HTTP request. + * + * AUTH IS THE CALLER'S OWN CREDENTIALS, FORWARDED. Every /api/v1 route accepts + * `Api-Token: :` and validates it against IAAM OAuth2 v2 — the same + * identity resolution a minted bearer token produces, one hop earlier. Verified in + * browserstack/teststack: the 59 v1 controllers inheriting ApplicationApiController resolve + * it in `current_user`, the 5 inheriting Api::V1::ApiController in `authenticate_token`. + * + * Note HTTP Basic is NOT usable on /api/v1 — `authenticate_with_authorization_header` never + * reaches the Basic path, so only those 5 controllers accept it. Api-Token is the one that + * works for the whole surface. + */ + +import { InvocationError } from "./index-loader.js"; + +export interface Credentials { + username: string; + accessKey: string; +} + +export interface HttpResponse { + status: number; + body: unknown; + error?: string; +} + +export type Transport = ( + method: string, + url: string, + headers: Record, + query: Record, + body?: unknown, +) => Promise; + +export function authHeaders(credentials: Credentials): Record { + if (!credentials?.username || !credentials?.accessKey) { + // Refusing here beats sending unauthenticated and surfacing the product's 401, which + // reads like the user's problem when it is our missing configuration. + throw new InvocationError( + "this request is not authenticated: BrowserStack username and access key are required", + ); + } + return { + "Api-Token": `${credentials.username}:${credentials.accessKey}`, + // Attribution, so the downstream service can see the call came from an agent. + "request-source": "ai-chatbot", + "Content-Type": "application/json", + }; +} + +/** A fetch-based transport. Redirects are NOT followed. */ +export function fetchTransport(timeoutMs = 45_000): Transport { + return async (method, url, headers, query, body) => { + const target = new URL(url); + for (const [key, value] of Object.entries(query || {})) { + if (value !== undefined && value !== null) target.searchParams.set(key, String(value)); + } + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(target.toString(), { + method, + headers, + // Only send a body when there IS one: a literal `null` payload with a JSON + // content-type is rejected by several endpoints. + body: body === undefined ? undefined : JSON.stringify(body), + // A redirect from an authenticated API is usually a login bounce, and following it + // turns a clear 401/302 into a 200 carrying an HTML sign-in page — which the + // resolver would then read as an empty result set rather than a failure. + redirect: "manual", + signal: controller.signal, + }); + let parsed: unknown = null; + const contentType = response.headers.get("content-type") || ""; + if (contentType.includes("json")) { + parsed = await response.json().catch(() => null); + } + return { status: response.status, body: parsed }; + } catch { + // Upstream detail stays out of the reply; the resolver treats status 0 as a failed call. + return { status: 0, body: null, error: "the product could not be reached" }; + } finally { + clearTimeout(timer); + } + }; +} diff --git a/src/tools/capability-registry/envelope.ts b/src/tools/capability-registry/envelope.ts new file mode 100644 index 0000000..ce28de3 --- /dev/null +++ b/src/tools/capability-registry/envelope.ts @@ -0,0 +1,147 @@ +/** + * Telling a record apart from the envelope around it, and what may be published when the + * product declares no schema at all. + * + * Ported from the Python side, where every rule here was learned from a live failure. + */ + +/** Field names that describe the RESPONSE rather than a record. */ +const ENVELOPE_NAMES = new Set([ + "success", "status_code", "self", "info", "meta", "errors", "error", "message", + "page", "page_size", "per_page", "prev", "next", "count", "total", "total_count", + "current_page", "last_page", "has_more", +]); + +const ENVELOPE_PATTERNS = [/^total(_|$)/, /_pages?$/, /^(has|is)_/, /^empty(_|$)/]; + +export function isEnvelopeField(name: string): boolean { + const lowered = (name || "").trim().toLowerCase(); + if (ENVELOPE_NAMES.has(lowered)) return true; + return ENVELOPE_PATTERNS.some((pattern) => pattern.test(lowered)); +} + +/** The subset of a declared `returns` that could plausibly be on a row. */ +export function rowFields(returns: string[] | undefined): string[] { + return (returns || []).filter((name) => !isEnvelopeField(name)); +} + +/** + * Rows found by SHAPE, not by name. + * + * A hardcoded key list was wrong and quietly so: tm uses 30 distinct row-key names across + * its responses (`histories`, `attachments`, `steps`, `path_folders`, `duplicates`, …), and + * a list of 13 missed 24 of them over 35 operations. + */ +const PREFERRED_ITEM_KEYS = ["items", "projects", "test_cases", "data", "results", "folders", + "test_runs", "test_plans", "plans", "reports", "datasets"]; + +/** Object properties that describe the response, so a lone row is never mistaken for one. */ +const ENVELOPE_OBJECT_KEYS = new Set([ + "info", "meta", "links", "pagination", "page_info", "errors", "error", "self", "_links", +]); + +const TOTAL_KEYS = ["total", "total_count", "count"]; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function itemsOf(body: unknown): Record[] { + if (Array.isArray(body)) return body.filter(isRecord); + if (!isRecord(body)) return []; + + const candidates = new Map[]>(); + for (const [key, value] of Object.entries(body)) { + if (Array.isArray(value) && value.some(isRecord)) { + candidates.set(key, value.filter(isRecord)); + } + } + if (candidates.size > 0) { + for (const key of PREFERRED_ITEM_KEYS) { + const hit = candidates.get(key); + if (hit) return hit; + } + return candidates.values().next().value as Record[]; + } + + // ANY array-valued property marks the row LOCATION, so an empty one is a genuine empty + // answer. "no rows" must stay distinguishable from "this shape has no rows in it". + if (Object.values(body).some((value) => Array.isArray(value))) return []; + + // A SINGLE-RECORD RESPONSE IS ONE ROW. Without this, every stats/summary/detail read came + // back ok:true, count:0, items:[] — the worst available failure, because it is not an + // error: the product answered fully and the resolver could not see it. + const nested = Object.entries(body).filter( + ([key, value]) => isRecord(value) && Object.keys(value).length > 0 && + !ENVELOPE_OBJECT_KEYS.has(key), + ); + if (nested.length === 1) return [nested[0][1] as Record]; + return [body]; +} + +/** Declared total, if the envelope carries one. tm puts it under `info`. */ +export function totalOf(body: unknown): number | undefined { + if (!isRecord(body)) return undefined; + const containers = [body.info, body.meta, body]; + for (const container of containers) { + if (!isRecord(container)) continue; + for (const key of TOTAL_KEYS) { + const value = container[key]; + if (typeof value === "number" && Number.isInteger(value)) return value; + } + } + return undefined; +} + +// ---- discovery mode --------------------------------------------------------------- +// +// `returns` is an allowlist, which is the right default. But 32 of tm's 173 operations +// declare a bare `{type: object}` response, so there is no field name to allowlist and the +// capability would be excluded — 19% of the surface, including "list test plans". The +// choice is not "safe capability vs unsafe capability", it is "discovered shape vs no +// capability at all". Two limits make it acceptable, and both are load-bearing: +// +// * SCALARS ONLY — a nested object is never expanded, which contains the blast radius of +// an unknown field. The harness records that `assignee` expands to email / full_name / +// browserstack_user_id and that custom-field `field_values` echo signed URLs; both are +// objects, so neither can travel this path. +// * A NAME DENYLIST for the scalars that remain. +const SENSITIVE_MARKERS = [ + "token", "secret", "password", "access_key", "api_key", "apikey", "private", + "credential", "signature", "email", "phone", "browserstack_user_id", "session_id", +]; +const SENSITIVE_EXACT = new Set(["user_id", "group_id"]); + +/** A row is a record, not a table dump. */ +export const MAX_DISCOVERED_FIELDS = 24; + +export function isSensitiveField(name: string): boolean { + const lowered = (name || "").toLowerCase(); + if (SENSITIVE_EXACT.has(lowered)) return true; + return SENSITIVE_MARKERS.some((marker) => lowered.includes(marker)); +} + +export function discoveredFields(row: Record): Record { + const out: Record = {}; + for (const [name, value] of Object.entries(row || {})) { + if (value !== null && typeof value === "object") continue; // scalars only + if (isSensitiveField(name) || isEnvelopeField(name)) continue; + out[name] = value; + if (Object.keys(out).length === MAX_DISCOVERED_FIELDS) break; + } + return out; +} + +/** Keep only the declared fields, or the discovered scalars when nothing is declared. */ +export function projectRow( + row: Record, + returns: string[] | undefined, + discover: boolean, +): Record { + if (returns && returns.length > 0) { + const out: Record = {}; + for (const name of returns) if (name in row) out[name] = row[name]; + return out; + } + return discover ? discoveredFields(row) : {}; +} diff --git a/src/tools/capability-registry/index-loader.ts b/src/tools/capability-registry/index-loader.ts new file mode 100644 index 0000000..ad325cd --- /dev/null +++ b/src/tools/capability-registry/index-loader.ts @@ -0,0 +1,92 @@ +/** + * Load the index artifact and expose the two lookups the tools need. + */ + +import { readFileSync } from "node:fs"; +import { + Capability, + RegistryIndex, + SUPPORTED_SCHEMA_VERSION, +} from "./types.js"; + +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 function endpointKey(method: string, path: string): string { + return `${(method || "").trim().toUpperCase()} ${(path || "").trim()}`; +} + +export class CapabilityRegistry { + readonly index: RegistryIndex; + /** product -> "METHOD /path" -> capability */ + private readonly byEndpoint = new Map>(); + + constructor(index: RegistryIndex) { + if (index?.schema_version !== SUPPORTED_SCHEMA_VERSION) { + // Refuse rather than best-effort read: a shape change the generator announced is + // exactly the case where guessing produces silently wrong tool output. + throw new IndexError( + `unsupported index schema_version ${index?.schema_version}; this build reads ` + + `${SUPPORTED_SCHEMA_VERSION}. Rebuild the artifact or update the server.`, + ); + } + if (!index.products || Object.keys(index.products).length === 0) { + throw new IndexError("index contains no products"); + } + this.index = index; + for (const [product, bundle] of Object.entries(index.products)) { + const lookup = new Map(); + for (const capability of bundle.capabilities) { + lookup.set(endpointKey(capability.method, capability.path), capability); + } + this.byEndpoint.set(product, lookup); + } + } + + static fromFile(file: string): CapabilityRegistry { + return new CapabilityRegistry(JSON.parse(readFileSync(file, "utf8")) as RegistryIndex); + } + + get buildId(): string { + return this.index.build_id; + } + + productNames(): string[] { + return Object.keys(this.index.products).sort(); + } + + /** + * Find a capability by the endpoint it exposes — the published handle. + * + * The endpoint is what searchCapability returns, so it is the only thing a caller can + * hold. `unknown_endpoint` is a defined outcome rather than a generic failure: a caller + * working from stale search output needs to know to search again, not to retry. + */ + byEndpointLookup(method: string, path: string, product?: string): { + product: string; + capability: Capability; + } { + const key = endpointKey(method, path); + const matches: { product: string; capability: Capability }[] = []; + for (const [name, lookup] of this.byEndpoint) { + if (product && name !== product) continue; + const capability = lookup.get(key); + if (capability) matches.push({ product: name, capability }); + } + if (matches.length === 0) { + throw new InvocationError( + `unknown_endpoint: ${key}. Search again — send \`method\` and \`path\` exactly as ` + + `searchCapability returned them, placeholders included.`, + ); + } + if (matches.length > 1 && !product) { + const owners = matches.map((m) => m.product).sort().join(", "); + throw new InvocationError( + `${key} exists in several products (${owners}); pass product`, + ); + } + return matches[0]; + } +} diff --git a/src/tools/capability-registry/register.ts b/src/tools/capability-registry/register.ts new file mode 100644 index 0000000..0ef3dc7 --- /dev/null +++ b/src/tools/capability-registry/register.ts @@ -0,0 +1,190 @@ +/** + * The tool surface: four discovery tools plus ONE invoke tool. + * + * ONE invoke tool means one set of MCP annotations, so they describe the whole surface + * honestly: it can write (not read-only) and it can never delete, because destructive + * endpoints are refused before binding. Write consent therefore rests on `user_permission` + * enforced HERE rather than on a client-side hint — which is the one thing a separate + * read/write tool pair was buying. + */ + +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; + +import { GroupedArguments } from "./bind.js"; +import { Credentials, Transport, fetchTransport } from "./egress.js"; +import { CapabilityRegistry, InvocationError } from "./index-loader.js"; +import { invoke } from "./resolve.js"; +import { searchCapabilities } from "./search.js"; +import { Mode } from "./types.js"; + +export const PERMISSION_VALUES = ["not_asked", "granted", "denied"] as const; + +export interface RegistryDeps { + registry: CapabilityRegistry; + /** Per-product base URL. Never baked into the artifact — it is environment-specific. */ + baseUrlFor: (product: string) => string; + credentialsFor: () => Credentials; + transport?: Transport; +} + +function ok(payload: unknown): CallToolResult { + return { content: [{ type: "text", text: JSON.stringify(payload) }] }; +} + +function failed(message: string): CallToolResult { + return { content: [{ type: "text", text: JSON.stringify({ ok: false, error: message }) }], isError: true }; +} + +export function addCapabilityRegistryTools(server: McpServer, deps: RegistryDeps): void { + const { registry } = deps; + const transport = deps.transport || fetchTransport(); + + server.tool( + "listProducts", + "List the BrowserStack products this surface can reach, with a one-line summary each. " + + "Start here when you do not know which product a task belongs to.", + {}, + async () => ok({ + build_id: registry.buildId, + products: registry.productNames().map((name) => ({ + name, summary: registry.index.products[name].summary, + })), + }), + ); + + server.tool( + "listEntities", + "List the entities a product models (test case, folder, test plan, …). Use it to scope " + + "searchCapability, or to find the entity name describeEntity wants.", + { product: z.string().describe("Product name from listProducts.") }, + async ({ product }) => { + const bundle = registry.index.products[product]; + if (!bundle) return failed(`unknown product '${product}'`); + return ok({ product, entities: Object.keys(bundle.entities).sort() }); + }, + ); + + server.tool( + "describeEntity", + "Describe one entity: what it is, what identifies it, what it relates to, and the " + + "vocabulary the product uses for it. Read this before filtering or writing, because " + + "ids and field values usually have to be resolved first.", + { + product: z.string().describe("Product name from listProducts."), + entity: z.string().describe("Entity name from listEntities."), + }, + async ({ product, entity }) => { + const bundle = registry.index.products[product]; + if (!bundle) return failed(`unknown product '${product}'`); + const doc = bundle.entities[entity]; + if (!doc) { + return failed( + `unknown entity '${entity}' in ${product}; known: ${Object.keys(bundle.entities).sort().join(", ")}`, + ); + } + return ok({ product, entity, ...doc }); + }, + ); + + server.tool( + "searchCapability", + "Find endpoints this surface can call, by plain language, optionally narrowed to one " + + "entity, product or mode. Each result carries the endpoint's `method` and `path` plus " + + "its parameters grouped into path_params / query / body under the spec's own names — " + + "pass them straight back to invokeEndpoint, no renaming. `guidance` is how to call it " + + "correctly; `mode` tells you whether it writes. Results are ranked and capped, and " + + "`truncated` says when more matched. Search before invoking.", + { + query: z.string().describe("What you are trying to do, in plain language."), + entity: z.string().optional().describe("Restrict to one entity (see listEntities)."), + product: z.string().optional().describe("Restrict to one product."), + mode: z.enum(["read", "write", "destructive"]).optional() + .describe("Restrict to reads or writes. Omit to let the query decide."), + limit: z.number().optional().describe("Max results (default 8)."), + }, + async ({ query, entity, product, mode, limit }) => ok({ + build_id: registry.buildId, + ...searchCapabilities(registry.index.products, query, { + entity, product, mode: mode as Mode | undefined, limit, + }), + }), + ); + + server.tool( + "invokeEndpoint", + "Call an endpoint returned by searchCapability. Pass `method` and `path` exactly as " + + "given, with arguments grouped into path_params / query / body under the spec's own " + + "names. Paging is handled for you — a read is complete unless `complete` is false; use " + + "order_by (prefix '-' to reverse) and top_n to sort and trim rather than fetching " + + "everything. If the endpoint's mode is 'write' you MUST ask the user first, then " + + "resend with user_permission='granted' and a change_summary; both are recorded. " + + "Endpoints 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.", + { + method: z.string().describe("HTTP method, exactly as searchCapability returned it."), + path: z.string().describe("Path with {placeholders} intact, exactly as returned."), + path_params: z.record(z.string(), z.any()).optional().describe("Values for the {placeholders}."), + query: z.record(z.string(), z.any()).optional().describe("Query parameters."), + body: z.record(z.string(), z.any()).optional().describe("Body fields, under the spec's names."), + product: z.string().optional().describe("Required only if two products share the endpoint."), + user_permission: z.enum(PERMISSION_VALUES).optional() + .describe("Set to 'granted' only after the user has confirmed a write."), + change_summary: z.string().optional().describe("What will change. Required for writes."), + order_by: z.string().optional().describe("A returns field; prefix '-' to reverse."), + top_n: z.number().optional().describe("Keep only the first N rows after ordering."), + }, + async (input): Promise => { + try { + const { product, capability } = registry.byEndpointLookup( + input.method, input.path, input.product, + ); + const args: GroupedArguments = { + path_params: input.path_params, query: input.query, body: input.body, + }; + + if (capability.mode === "destructive") { + // Refused before binding, so consent is never sought for something that cannot run. + return failed( + `${input.method} ${input.path} is a destructive operation and is not available ` + + `through this surface`, + ); + } + + if (capability.mode === "write") { + const permission = input.user_permission || "not_asked"; + // PARAMETERS ARE VALIDATED BEFORE PERMISSION IS DEMANDED. The gate used to run + // first, so a caller with a typo'd parameter was told "ask the user to confirm this + // change", went back to the human for approval, and only then learned the parameter + // was wrong. A dry bind costs nothing and cannot mutate. + const { bind } = await import("./bind.js"); + bind(capability, args); + if (permission !== "granted") { + // Catches the careless path, not the adversarial one: the model fills this field + // in, so it is an audit record and a speed bump, never authorisation. + return failed( + "refused: this endpoint changes data — ask the user to confirm, then retry " + + "with user_permission='granted' and a change_summary", + ); + } + if (!(input.change_summary || "").trim()) { + return failed("change_summary is required: state what will change"); + } + } + + const result = await invoke( + capability, args, deps.baseUrlFor(product), deps.credentialsFor(), transport, + { orderBy: input.order_by, topN: input.top_n }, + ); + return ok(result); + } catch (error) { + if (error instanceof InvocationError) return failed(error.message); + throw error; + } + }, + ); +} + +export default addCapabilityRegistryTools; diff --git a/src/tools/capability-registry/resolve.ts b/src/tools/capability-registry/resolve.ts new file mode 100644 index 0000000..f421534 --- /dev/null +++ b/src/tools/capability-registry/resolve.ts @@ -0,0 +1,139 @@ +/** + * Invoke one endpoint: page it to completion, find the rows, project them. + */ + +import { bind, GroupedArguments } from "./bind.js"; +import { itemsOf, projectRow, rowFields, totalOf } from "./envelope.js"; +import { authHeaders, Credentials, Transport } from "./egress.js"; +import { InvocationError } from "./index-loader.js"; +import { Capability } from "./types.js"; + +/** A backstop, not a budget: a runaway pager is a bug, and 60 pages is past any real read. */ +export const MAX_PAGES = 60; + +export interface InvokeResult { + ok: boolean; + count: number; + items: Record[]; + complete: boolean; + requests_made: number; + total_reported?: number; + truncated_reason?: "page_cap" | "max_items" | "top_n"; + error?: string; +} + +export interface InvokeOptions { + orderBy?: string; + topN?: number; + pageSize?: number; +} + +function pagingParams(capability: Capability): { page?: string; size?: string; max: number } { + // The artifact hides the page/count parameter NAMES (they are the resolver's, not the + // caller's) but publishes `paginated` and `max_items`. tm's own convention is `p` for the + // page and `count`/`per_page` for the size, and the declared ceiling travels as max_items. + if (!capability.paginated) return { max: 0 }; + const query = new Set((capability.query || []).map((param) => param.name)); + const size = ["count", "per_page", "page_size"].find((name) => query.has(name)); + return { page: query.has("p") ? "p" : query.has("page") ? "page" : undefined, size, max: capability.max_items || 0 }; +} + +export async function invoke( + capability: Capability, + args: GroupedArguments, + baseUrl: string, + credentials: Credentials, + transport: Transport, + options: InvokeOptions = {}, +): Promise { + if (!baseUrl) throw new InvocationError("no base URL is configured for that product"); + const bound = bind(capability, args); + const headers = authHeaders(credentials); + const paging = pagingParams(capability); + + const items: Record[] = []; + const declared = rowFields(capability.returns); + const discover = capability.shape === "discovered"; + let requests = 0; + let rowsSeen = 0; + let total: number | undefined; + let complete = true; + let truncated: InvokeResult["truncated_reason"]; + + for (let page = 1; page <= MAX_PAGES; page += 1) { + const query: Record = { ...bound.query }; + if (paging.page) query[paging.page] = page; + // Ask for the largest page the operation declares. Paging at the product's default was + // the 17 Aug failure: 880 projects walked 30 at a time. + if (paging.size && !(paging.size in query)) { + query[paging.size] = options.pageSize || paging.max || undefined; + if (query[paging.size] === undefined) delete query[paging.size]; + } + + const response = await transport( + capability.method, `${baseUrl.replace(/\/$/, "")}${bound.path}`, + headers, query, bound.body, + ); + requests += 1; + + if (response.status === 0 || response.status < 200 || response.status >= 300) { + return { + ok: false, count: items.length, items, complete: false, requests_made: requests, + error: response.error || + `the product answered ${response.status}`, + }; + } + + const rows = itemsOf(response.body); + rowsSeen += rows.length; + total = totalOf(response.body) ?? total; + for (const row of rows) items.push(projectRow(row, declared, discover)); + + if (!paging.page || rows.length === 0) break; + if (total !== undefined && rowsSeen >= total) break; + if (page === MAX_PAGES) { complete = false; truncated = "page_cap"; } + } + + // A DRIFT GUARD, not a retry hint. The product answered with rows and none of them + // carried a single field this capability declares, which is a registration defect — + // trying different arguments will not help. + if (rowsSeen > 0 && items.every((row) => Object.keys(row).length === 0)) { + return { + ok: false, count: 0, items: [], complete: false, requests_made: requests, + error: discover + ? "capability_shape_empty: the product answered with rows, but every field on them " + + "was an object, an array, or a name withheld as sensitive." + : `capability_returns_drift: the product answered with rows, but none of the fields ` + + `this capability declares (${declared.join(", ") || "none"}) were present.`, + }; + } + + let out = items; + if (options.orderBy) { + const field = options.orderBy.replace(/^-/, ""); + const descending = options.orderBy.startsWith("-"); + if (declared.length > 0 && !declared.includes(field)) { + throw new InvocationError( + `order_by must name one of this endpoint's returns fields: ${declared.join(", ")}`, + ); + } + // Rows missing the field sort last in either direction rather than crashing on null. + out = [...items].sort((a, b) => { + const left = a[field], right = b[field]; + if (left === undefined || left === null) return 1; + if (right === undefined || right === null) return -1; + const cmp = left < right ? -1 : left > right ? 1 : 0; + return descending ? -cmp : cmp; + }); + } + if (options.topN && options.topN > 0 && out.length > options.topN) { + out = out.slice(0, options.topN); + truncated = truncated || "top_n"; + } + + return { + ok: true, count: out.length, items: out, complete, requests_made: requests, + ...(total !== undefined ? { total_reported: total } : {}), + ...(truncated ? { truncated_reason: truncated } : {}), + }; +} diff --git a/src/tools/capability-registry/search.ts b/src/tools/capability-registry/search.ts new file mode 100644 index 0000000..8dbdd7e --- /dev/null +++ b/src/tools/capability-registry/search.ts @@ -0,0 +1,154 @@ +/** + * Ranking capabilities against a plain-language query. + * + * Ported from the Python `discover._score`, including the two properties that were each + * fixed after a live mis-ranking: + * + * * PENALTIES REORDER, THEY DO NOT EXCLUDE. `matched` is the pre-penalty term score and is + * what decides inclusion; `ranked` carries the preferences. Conflating them dropped 40 + * legitimate matches outright, because a cardinality penalty took an otherwise-valid + * score to zero and the caller saw "no such capability". + * * CARDINALITY. A "list" query answered by a single-record getter sends the caller to a + * capability needing an id it cannot possibly have yet. + */ + +import { Capability, EntityDoc, Mode, ProductIndex } from "./types.js"; + +const WORD = /[a-z0-9_]+/g; + +const STOPWORDS = new Set([ + "a", "an", "and", "are", "as", "at", "be", "by", "can", "do", "for", "from", + "how", "i", "in", "is", "it", "me", "my", "of", "on", "or", + "that", "the", "to", "want", "what", "which", "with", "you", +]); + +// Verbs that reveal what the caller means to DO. A preference, not a filter — an explicit +// `mode` argument is the filter. +const READ_VERBS = new Set(["list", "get", "show", "find", "fetch", "read", "count", + "search", "view", "which", "how"]); +const WRITE_VERBS = new Set(["create", "add", "update", "edit", "delete", "remove", "move", + "copy", "archive", "assign", "restore", "reorder", "bulk", "set", "upload", "import", + "clone"]); + +// Words that mean "give me many", which is what makes a single-record getter the wrong answer. +const PLURAL_INTENT = new Set(["list", "all", "every", "many", "count", "search", "find", + "which", "each"]); + +/** Query/haystack terms. Verbs are deliberately NOT stopwords — they carry the intent. */ +export function terms(text: string | undefined): string[] { + return [...((text || "").toLowerCase().matchAll(WORD))] + .map((match) => match[0]) + .filter((word) => !STOPWORDS.has(word)); +} + +export function modeHint(query: string | undefined): "" | Mode { + const words = new Set(terms(query)); + const wantsWrite = [...words].some((word) => WRITE_VERBS.has(word)); + if (wantsWrite) return "write"; + const wantsRead = [...words].some((word) => READ_VERBS.has(word)); + return wantsRead ? "read" : ""; +} + +export function wantsCollection(query: string | undefined): boolean { + return [...((query || "").toLowerCase().matchAll(WORD))] + .some((match) => PLURAL_INTENT.has(match[0])); +} + +/** + * True when a capability answers with many records rather than one. + * + * Pagination is the reliable signal — a paged operation is a listing by construction. The + * plural terminal path segment is a weaker fallback for unpaged collections. (The Python + * side used the capability NAME here; the artifact publishes no name, and the path's own + * terminal noun carries the same signal because operationIds were derived from it.) + */ +export function isCollection(capability: Capability): boolean { + if (capability.paginated) return true; + const segments = capability.path.split("/").filter((s) => s && !s.startsWith("{")); + const tail = segments[segments.length - 1] || ""; + return tail.endsWith("s") && !tail.endsWith("ss"); +} + +/** Path words stand in for the capability name as the identity haystack. */ +function identityText(capability: Capability): string { + return capability.path + .split("/") + .filter((segment) => segment && !segment.startsWith("{") && segment !== "api") + .join(" ") + .replace(/[-_]/g, " "); +} + +function score( + capability: Capability, + wanted: string[], + aliases: Record, + hint: "" | Mode, + plural: boolean, +): { matched: number; ranked: number } { + if (wanted.length === 0) return { matched: 1, ranked: 1 }; + + const haystacks: [string, number][] = [ + [identityText(capability), 6], + [capability.entity.replace(/_/g, " "), 4], + [(aliases[capability.entity] || []).join(" "), 4], + [capability.intent || "", 2], + // `returns` is scored BELOW identity, not gated on it. At parity with intent it put a + // projects listing at #2 for "list test cases in a project" (its returns carries + // `test_cases_count`); gating it on an identity match instead made a field reachable + // only through returns unreachable, which is worse. + [(capability.returns || []).join(" ").replace(/_/g, " "), 1], + [(capability.guidance || []).join(" "), 1], + ]; + + let ranked = 0; + for (const [text, weight] of haystacks) { + const blob = new Set(terms(text)); + ranked += weight * wanted.filter((term) => blob.has(term)).length; + } + const matched = ranked; + + if (hint && capability.mode !== hint) ranked -= 20; + else if (hint && capability.mode === hint) ranked += 6; + if (plural) ranked += isCollection(capability) ? 8 : -8; + + return { matched, ranked }; +} + +export interface SearchResult { + capabilities: Capability[]; + truncated: boolean; + total_matched: number; +} + +export function searchCapabilities( + products: Record, + query?: string, + options: { entity?: string; product?: string; mode?: Mode; limit?: number } = {}, +): SearchResult { + const limit = options.limit && options.limit > 0 ? options.limit : 8; + const wanted = terms(query); + const hint = options.mode ? "" : modeHint(query); + const plural = wantsCollection(query); + + const scored: { matched: number; ranked: number; capability: Capability }[] = []; + for (const [name, bundle] of Object.entries(products)) { + if (options.product && name !== options.product) continue; + const aliases: Record = {}; + for (const [entity, doc] of Object.entries(bundle.entities)) { + aliases[entity] = ((doc as EntityDoc).aliases || []) as string[]; + } + for (const capability of bundle.capabilities) { + if (options.entity && capability.entity !== options.entity) continue; + if (options.mode && capability.mode !== options.mode) continue; + const { matched, ranked } = score(capability, wanted, aliases, hint, plural); + if (matched > 0) scored.push({ matched, ranked, capability }); + } + } + scored.sort((a, b) => b.ranked - a.ranked || + a.capability.path.localeCompare(b.capability.path)); + return { + capabilities: scored.slice(0, limit).map((row) => row.capability), + truncated: scored.length > limit, + total_matched: scored.length, + }; +} diff --git a/src/tools/capability-registry/types.ts b/src/tools/capability-registry/types.ts new file mode 100644 index 0000000..dcc84d7 --- /dev/null +++ b/src/tools/capability-registry/types.ts @@ -0,0 +1,76 @@ +/** + * The shape of the index artifact, which is the contract with the Python build. + * + * The artifact is generated by `capability-registry/scripts/build_index.py` and contains + * ONLY data that has already passed that side's outbound boundary. Nothing here is parsed + * from an OpenAPI spec at runtime: if this half parsed specs it would become the boundary, + * and every gate (route lint, vocabulary rule, intent lint, discovery denylist) would have + * to be reimplemented and re-tested here. Reading a pre-projected index means the internal + * data is not in the package at all. + */ + +/** Bumped by the generator when the artifact's shape changes. */ +export const SUPPORTED_SCHEMA_VERSION = 1; + +export type Mode = "read" | "write" | "destructive"; + +/** One parameter, under the name the OpenAPI spec itself gives it. */ +export interface WireParam { + name: string; + type: string; + required?: true; + values?: unknown[]; + example?: unknown; + description?: string; + /** Field names/types one level inside an array item or nested object. */ + fields?: { name: string; type: string; required?: true }[]; + /** + * Where a body field sits in the JSON, when that differs from its name. Published + * because the nesting is not guessable and getting it wrong fails silently — tm's folder + * create really wants `{folder: {name}}` while the spec's flat `{name}` is what a reader + * would assume. + */ + json_path?: string; +} + +/** A capability, keyed by the endpoint it exposes. There is deliberately no name. */ +export interface Capability { + method: string; + path: string; + mode: Mode; + entity: string; + path_params?: WireParam[]; + query?: WireParam[]; + body?: WireParam[]; + intent?: string; + guidance?: string[]; + /** Allowlisted row fields. Absent when `shape` is "discovered". */ + returns?: string[]; + /** "discovered" when the product declares no response schema for this operation. */ + shape?: "discovered"; + requires?: string[]; + paginated?: true; + max_items?: number; +} + +export interface EntityDoc { + title?: string; + aliases?: string[]; + id_convention?: string; + parents?: string[]; + relations?: { entity?: string; via?: string }[]; + [key: string]: unknown; +} + +export interface ProductIndex { + summary: string; + capabilities: Capability[]; + entities: Record; +} + +export interface RegistryIndex { + schema_version: number; + build_id: string; + harness_commit?: string; + products: Record; +} diff --git a/tests/fixtures/registry-index.json b/tests/fixtures/registry-index.json new file mode 100644 index 0000000..73efebb --- /dev/null +++ b/tests/fixtures/registry-index.json @@ -0,0 +1 @@ +{"schema_version":1,"build_id":"3c872d0b7b-173caps-e0e55b7","harness_commit":"e0e55b7e","products":{"tm":{"summary":"BrowserStack Test Management API (v1 \u2014 the SSO/OAuth app API, cookie-authenticated).","capabilities":[{"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"}],"body":[{"name":"owner","type":"integer","description":"The ID of the user to assign as the owner of the test run."}],"intent":"Assign a owner to the test run","guidance":["Assign a user as the owner of a specific test run within a project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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, verified live: 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":"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":["Archive multiple test cases within a project","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"paginated":true},{"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,"description":"Plan INTEGER ids."}],"intent":"Archive test plans in bulk (reversible \u2014 prefer this over delete)","guidance":["REVERSIBLY remove plans from view","Async above the bulk threshold","Soft-archive one or more plans","reach for it whenever the user wants plans \"removed\" or \"cleaned up\" without saying they must be destroyed"],"returns":["async","unique_id"]},{"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"}],"query":[{"name":"include_subfolders_tc","type":"boolean"}],"body":[{"name":"assignee_id","type":"integer","example":2,"description":"ID of the assignee to whom the test cases will be assigned"},{"name":"mapping_ids","type":"array","example":[999,991,1024,1019],"description":"List of test case IDs to be linked"},{"name":"select_all","type":"boolean","example":false,"description":"Flag to select all test cases"},{"name":"de_selected_ids","type":"array","description":"List of test case IDs to be de-selected"},{"name":"folder_id","type":"integer","description":"ID of the folder to which the test cases belong"}],"intent":"Bulk assign test cases to a test run","guidance":["assign specific cases within a run to a user (async above 300)","Assign multiple test cases to a specific test run within a project, allowing for bulk operations on test case assignments"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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` \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, verified live: an unrecognised key here is SILENTLY DROPPED, so a typo widens the ta"},{"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,"json_path":"/test_case/ids"},{"name":"de_selected_ids","type":"array","required":true,"json_path":"/test_case/de_selected_ids"},{"name":"select_all","type":"boolean","required":true,"json_path":"/test_case/select_all"},{"name":"folder_id","type":"string","description":"SOURCE folder that scopes the selection, at top level. OPTIONAL when you pass explicit `ids` (verified live: the copy succeeds without it), but send it whenever `select_all` is true \u2014 it is what bounds the set, and `select_all` with `folder_id` and no `q` copies exactly that folder's cases. Integer and string are both accepted; the declared string matches what the UI sends here, which unlike bulk-"},{"name":"destination_folder_id","type":"integer","required":true,"description":"Target folder for the copies, and the one genuinely required destination field \u2014 omitting it is a bare `400`, verified live. NOTE it is TOP-LEVEL for copy, unlike bulk-move where the destination sits inside `test_case`."},{"name":"destination_project_id","type":"string","description":"Target project id. Needed only for a copy to ANOTHER project. For a copy WITHIN one project it is OPTIONAL \u2014 verified live, omitting it copies correctly into `destination_folder_id`, and integer and string are both accepted. The product does both: its Copy/Move modal sends the source project id, its clone / drag-and-drop path omits it. Send the source id to be explicit or omit it; never invent a v"}],"intent":"Bulk copy test cases","guidance":["copy a selection to a folder (async above 30)","Bulk copy test cases from one folder to another within a project","This operation allows you to copy multiple test cases at once, which can be useful for organizing or duplicating test cases across different folders","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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` \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, verified live: 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":"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 delete.","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":"Delete every case in scope, minus `de_selected_ids`.","json_path":"/test_case/select_all"}],"intent":"Bulk Delete Test Cases from Search","guidance":["All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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."}],"intent":"Delete test results in bulk \u2014 PARTIAL SUCCESS is normal, always read `skipped`","guidance":["PARTIAL SUCCESS is normal, always read the skipped array back","Authorisation is enforced per id (a caller must be the result's author or a project admin)","treating a 200 as \"all deleted\" will silently misreport","result_ids must be a non-empty array (400 otherwise) of at most 300 ids (422 beyond that)","batch larger sets yourself"],"returns":["id","reason"]},{"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` \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, verified live: 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":"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 edit.","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":"automation_status","type":"string","json_path":"/test_case/automation_status"},{"name":"case_type","type":"integer","json_path":"/test_case/case_type"},{"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"},{"name":"owner","type":"integer","description":"TM user id.","json_path":"/test_case/owner"},{"name":"preconditions","type":"string","json_path":"/test_case/preconditions"},{"name":"priority","type":"integer","json_path":"/test_case/priority"},{"name":"status","type":"integer","json_path":"/test_case/status"},{"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"}],"intent":"Bulk Update Test Cases","guidance":["bare values, REPLACE-only (async above 100)","Update multiple test cases within a project","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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"}],"query":[{"name":"include_subfolders_tc","type":"boolean"},{"name":"status_id","type":"integer"}],"body":[{"name":"attachments","type":"array","example":[]},{"name":"de_selected_ids","type":"array","example":[]},{"name":"description","type":"string","example":"

something

","description":"HTML formatted comment or note"},{"name":"folder_id","type":"integer","example":21},{"name":"issues","type":"array","example":[]},{"name":"mapping_ids","type":"array","example":[999,991,1024,1019]},{"name":"select_all","type":"boolean","example":false},{"name":"status_id","type":"integer","example":21},{"name":"test_run_ids","type":"array","example":[1001,1002,1003],"description":"Array of BTCER IDs to apply the bulk edit to (passed when automation = true)"},{"name":"test_case_counts","type":"array","example":[1,3,2],"description":"Array of test case counts corresponding to each BTCER ID in test_run_ids (passed when automation = true)"}],"intent":"Bulk edit test cases result in test run","guidance":["Update multiple test cases in a specific test run within a project, allowing for bulk operations such as changing status, adding attachments, and more"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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, verified live: an unrecognised key here is SILENTLY DROPPED, so a typo widens the ta"},{"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},{"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},{"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`.\n\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},{"name":"value","type":"string"}],"json_path":"/test_case/priority"},{"name":"status","type":"string","fields":[{"name":"operation","type":"string","required":true},{"name":"value","type":"string"}],"json_path":"/test_case/status"},{"name":"case_type","type":"string","fields":[{"name":"operation","type":"string","required":true},{"name":"value","type":"string"}],"json_path":"/test_case/case_type"},{"name":"automation_state","type":"string","fields":[{"name":"operation","type":"string","required":true},{"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},{"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},{"name":"value","type":"string"}],"json_path":"/test_case/owner"},{"name":"preconditions","type":"string","description":"Text value.","fields":[{"name":"operation","type":"string","required":true},{"name":"value","type":"string"}],"json_path":"/test_case/preconditions"},{"name":"review_status","type":"string","fields":[{"name":"operation","type":"string","required":true},{"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},{"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":["PREFERRED bulk write","per-field { value, operation }","Correct for one case too (pass a single id)","Update many test cases in ONE call","Every field is an object of the form {\"value\": \u2026, \"operation\": \u2026}","so this is the only write path that can ADD or REMOVE from a collection without replacing it"],"returns":["async","unique_id"],"paginated":true},{"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` \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, verified live: 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":"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 move.","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":"Move every case in scope, minus `de_selected_ids`.","json_path":"/test_case/select_all"},{"name":"destination_folder_id","type":"integer","description":"Target folder id. Falls back to `folder_id` when omitted.","json_path":"/test_case/destination_folder_id"},{"name":"folder_id","type":"integer","description":"Legacy alias for `destination_folder_id`, used only when that is absent.","json_path":"/test_case/folder_id"},{"name":"destination_project_id","type":"integer","description":"Set only for a cross-project move. Shared cases cannot be moved across projects (422).","json_path":"/test_case/destination_project_id"}],"intent":"Bulk Move Test Cases","guidance":["Move multiple test cases from one folder to another within a project","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"paginated":true},{"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":"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, verified live: 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":"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 Retrieve Test Cases","guidance":["Retrieve multiple test cases within a project based on specified criteria","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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,"description":"Plan INTEGER ids."}],"intent":"Un-archive test plans in bulk (undo bulk-archive)","guidance":["Restore previously archived plans"],"returns":["async","unique_id"]},{"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":"Clone an exploratory session (async when it has many logs).","guidance":["clone a session (async when it has many logs)"]},{"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","description":"Name for the clone. Defaults to a server-generated copy name.","json_path":"/test_plan/name"},{"name":"copy_all_sub_plans","type":"boolean","json_path":"/test_plan/copy_all_sub_plans"},{"name":"sub_plan_ids","type":"array","description":"Clone only these sub-plans. Ignored when copy_all_sub_plans is true.","json_path":"/test_plan/sub_plan_ids"}],"intent":"Clone a test plan, optionally with its sub-plans","guidance":["copy_all_sub_plans or sub_plan_ids to bring its sub-plans along","Copy an existing plan","copy_all_sub_plans: true clones every sub-plan","alternatively pass sub_plan_ids to clone a specific subset","Omit both to clone just the plan itself"]},{"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"}],"body":[{"name":"copy_tc_assignee","type":"boolean","description":"Copy test case assignees from the original run"},{"name":"copy_issues","type":"boolean","description":"Copy issues linked to the original run"},{"name":"copy_tags","type":"boolean","description":"Copy tags from the original run"},{"name":"copy_all_cases","type":"boolean","description":"Include all test cases in the cloned run"},{"name":"status","type":"array","example":["passed","failed"],"description":"Status strings to filter test cases to copy"},{"name":"status_id","type":"array","example":[1,2],"description":"Status IDs to filter test cases to copy"},{"name":"name","type":"string","example":"Cloned Test Run - 2025","description":"Name for the new cloned test run"}],"intent":"Clone a test run","guidance":["Create a new test run by cloning an existing one, with options to copy test case assignees, issues, tags, and filter cases by status"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"Close an exploratory session","guidance":["Transitions an active exploratory session to the closed state"]},{"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"}],"intent":"Close a test run","guidance":["Close a specific test run within a project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"ID of folder to copy"},{"name":"destination_folder_id","type":"integer","required":true,"example":3,"description":"ID of destination parent folder"},{"name":"destination_project_id","type":"integer","required":true,"example":10000,"description":"Destination project ID (can be same or different)"},{"name":"async","type":"boolean","example":true,"description":"Whether to process asynchronously via background job"},{"name":"copy_test_cases","type":"boolean","example":true,"description":"Whether to copy test cases within folder"}],"intent":"Copy a folder to another location","guidance":["Copies a folder (and optionally its contents) to a destination folder within the same or different project","Supports both synchronous and asynchronous operations via the async flag","Async Processing: When async: true, the operation is queued as a Sidekiq background job and returns immediately with a unique tracking ID"],"returns":["async","folder_id","unique_id"]},{"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","example":false,"description":"Select every case in the project.","json_path":"/selection/select_all"},{"name":"unique_test_case_count","type":"integer","example":2,"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.","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`."},{"name":"configurations","type":"array","required":true,"example":[],"description":"Configuration ids. Required \u2014 pass an empty array if there are none."},{"name":"trtc_configuration_mappings","type":"array","description":"Per-configuration case mappings. An ARRAY of objects \u2014 the server rejects an object here.","fields":[{"name":"configuration_id","type":"integer"},{"name":"select_all","type":"boolean"},{"name":"linked_test_cases","type":"array"},{"name":"unlinked_test_cases","type":"array"}]}],"intent":"Count the test cases a selection resolves to (incl. configurations/datasets).","guidance":["count the cases a selection resolves to (incl"],"shape":"discovered"},{"method":"GET","path":"/api/v1/projects/{project_id}/test-plans/{test_plan_id}/test-runs/count","mode":"read","entity":"test_plan","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"test_plan_id","type":"integer","required":true}],"intent":"Count the test runs linked to a test plan","guidance":["how many runs a plan groups","cheaper and safer than paging the run list","Returns just the count of runs linked to the plan","Prefer this over paging the run list when the user only asked \"how many\"","list reads truncate around 30 KB and will make you under-count"],"shape":"discovered"},{"method":"POST","path":"/api/v1/projects/{project_id}/folder/{folder_id}/bulk-test-cases","mode":"write","entity":"folder","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"folder_id","type":"integer","required":true}],"body":[{"name":"test_cases","type":"array","required":true,"fields":[{"name":"name","type":"string","required":true},{"name":"test_case_folder_id","type":"string","required":true},{"name":"attachments","type":"array"},{"name":"automation_state","type":"integer"},{"name":"automation_status","type":"string"},{"name":"case_type","type":"integer"},{"name":"custom_fields","type":"object"},{"name":"description","type":"string"},{"name":"estimate","type":"string"},{"name":"estimated_duration_seconds","type":"integer"},{"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"},{"name":"reviewers","type":"array"},{"name":"send_for_review","type":"boolean"},{"name":"shared_precondition_id","type":"integer"},{"name":"status","type":"integer"},{"name":"tags","type":"array"},{"name":"template","type":"string"},{"name":"template_id","type":"integer"},{"name":"template_step_type","type":"string"},{"name":"test_case_dataset","type":"string"},{"name":"test_case_steps","type":"array"}]},{"name":"unique_id","type":"string","description":"Client-supplied idempotency key. When present and the group has the async bulk-create feature flag enabled, the request is processed asynchronously and acknowledged with 202; completion is delivered over the common WebSocket channel keyed by this id."}],"intent":"Create test cases in bulk for a folder (\u226410) \u2014 UNRELIABLE STATUS, see description.","guidance":["Creates several test cases in one request","more returns 400 \"More than permitted test cases sent\"","A 500 here does NOT mean nothing happened","The action is wrapped in a catch-all rescue that renders 500 {\"success\": false, \"message\": \"Failed to create test cases.\"} for any exception, including ones raised AFTER the cases were already inserted","Same status, same message, opposite outcomes","So on a 500: never retry blind"],"returns":["request_trace_id"]},{"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 new configuration for a project","guidance":["create a configuration ({ name })","Create a new configuration in a specific project"],"returns":["id","name","created_at","group_id","is_suggested","record_status"]},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"body":[{"name":"option_value","type":"string","required":true,"description":"The option label."},{"name":"is_default","type":"boolean","required":true,"description":"REQUIRED \u2014 a missing value is a 400. Must be 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 parent option this hangs under."}],"intent":"Add one option to a dataset \u2014 how you add a dropdown value.","guidance":["ADD one dropdown option","option_value + is_default both required","Adds a single option","Both option_value and is_default are required","a missing is_default is 400 \"Invalid Params\"","For dropdown, at most one option may be the default"]},{"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}],"body":[{"name":"linked_projects","type":"array","description":"Project INTEGER ids this dataset applies to. This is what makes the field visible in a project \u2014 a dataset with no linked projects leaves the field invisible everywhere. NOTE the spelling differs from the project-mappings endpoint, which uses `link_projects` / `unlink_projects`."},{"name":"link_to_future_projects","type":"boolean","description":"Auto-link projects created later."},{"name":"linked_to_all_projects","type":"boolean","description":"Apply to every project in the workspace."},{"name":"options","type":"array","description":"The allowed values, for dropdown-style fields.","fields":[{"name":"option_value","type":"string","required":true},{"name":"is_default","type":"boolean","required":true},{"name":"parent_option_id","type":"integer"}]}],"intent":"Add a dataset (an option set + its project links) to an existing field.","guidance":["add another option set + project scope to an existing field","A dataset is how a field carries options and project scope","A field can hold more than one, letting different projects see different option sets for the same field","the key is linked_projects, and options is accepted at this level (it is a dataset body, not a field body)","for other types a dataset with linked_projects and no options is what scopes the field to projects"]},{"method":"POST","path":"/api/v1/admin-v2/settings/custom-fields","mode":"write","entity":"custom_field","body":[{"name":"field_name","type":"string","required":true},{"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 (e.g. `field_dropdown`) are a different API's and are rejected. Silently immutable after creation: an update that changes it returns 200 and changes nothing."},{"name":"entity_type","type":"string","values":["TestCase","TestResult","TestPlan","ExploratorySession"],"description":"Which entity the field hangs off. One field belongs to exactly one."},{"name":"is_required","type":"boolean","required":true,"description":"REQUIRED, and must be a literal boolean \u2014 omitting it is a 400."},{"name":"datasets","type":"array","required":true,"description":"REQUIRED for every field type, dropdown or not \u2014 omitting it is a 400. Carries the options and the project scope. Send one entry with `linked_projects` set.","fields":[{"name":"linked_projects","type":"array"},{"name":"link_to_future_projects","type":"boolean"},{"name":"linked_to_all_projects","type":"boolean"},{"name":"options","type":"array"}]},{"name":"place_holder_text","type":"string"},{"name":"default_value","type":"string","description":"Only honoured when `field_type` is `boolean`."},{"name":"levels","type":"array","description":"REQUIRED when field_type is `nested_dropdown`, minimum 2 entries, each {name, level_type}."}],"intent":"Create a custom field DEFINITION (workspace-admin action).","guidance":["options AND project scope both go in datasets[]","Creates the field definition","a new attribute available on the chosen entity type","configurable per workspace","so don't predict access","A caller without it gets 403"]},{"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,"description":"Name of the dataset.","json_path":"/dataset/name"},{"name":"variables","type":"array","required":true,"description":"List of dataset variables/columns (max 40).","fields":[{"name":"name","type":"string","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/variables"},{"name":"rows","type":"array","required":true,"description":"List of dataset rows (max 100).","fields":[{"name":"row_number","type":"integer","required":true},{"name":"row_data","type":"array","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/rows"}],"intent":"Create a new dataset.","guidance":["create a dataset with variables + rows","Create a new dataset with variables and rows for a project"],"returns":["name","created_at","rows_count","uuid"]},{"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.","json_path":"/exploratory_session/title"},{"name":"description","type":"string","example":"Explore edge cases in the checkout flow.","description":"Optional description.","json_path":"/exploratory_session/description"},{"name":"charter","type":"string","example":"Focus on payment error handling","description":"Testing charter describing the scope and goals.","json_path":"/exploratory_session/charter"},{"name":"timebox_duration","type":"integer","example":60,"description":"Planned duration in minutes.","json_path":"/exploratory_session/timebox_duration"},{"name":"owner","type":"integer","example":42,"description":"TCM user ID of the session owner / assignee.","json_path":"/exploratory_session/owner"},{"name":"assignee","type":"integer","example":42,"description":"Alias for `owner`. TCM user ID of the assignee.","json_path":"/exploratory_session/assignee"},{"name":"folder_id","type":"integer","example":456,"description":"ID of the folder to place this session in.","json_path":"/exploratory_session/folder_id"},{"name":"tags","type":"array","example":["regression","mobile"],"description":"Tags for the session.","json_path":"/exploratory_session/tags"},{"name":"configurations","type":"array","example":[1,3],"description":"Configuration IDs to associate with this session.","json_path":"/exploratory_session/configurations"},{"name":"attachments","type":"array","example":[101,102],"description":"Blob / media attachment IDs.","json_path":"/exploratory_session/attachments"},{"name":"issues","type":"array","description":"Issues to link to this session.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/exploratory_session/issues"}],"intent":"Create an exploratory session","guidance":["body wrapped in exploratory_session (title required)","Creates a new exploratory session within the specified project"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"]},{"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 button \u2014 spinner appeared but order was not created.

","description":"Rich-text HTML content of the log entry.","json_path":"/session_log/content"},{"name":"status","type":"string","values":["pass","fail","bug","retest","block","note"],"example":"fail","description":"Test result status for this log step.","json_path":"/session_log/status"},{"name":"elapsed_time","type":"integer","example":120,"description":"Time elapsed for this step in seconds.","json_path":"/session_log/elapsed_time"},{"name":"defects","type":"array","description":"Defects to link to this log entry.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/session_log/defects"}],"intent":"Create a session log entry","guidance":["add a log entry (rich-text HTML, sanitised on write)","Adds a new log entry to the specified exploratory session","Rich-text HTML content is sanitised before storage"],"returns":["id","status","content","created_at","elapsed_time","session_id","updated_at"]},{"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,"description":"Name of the filter"},{"name":"entity","type":"string","required":true,"values":["testcase","testplan","testrun","exploratory_session","test_plan_test_case"],"description":"Entity type the filter applies to. The server's validator accepts five values (the\nlast two were missing here); an unlisted value returns 400 \"Invalid JSON data\"."},{"name":"is_project_level","type":"boolean","description":"Whether this filter is available at project level"},{"name":"filters","type":"object","required":true,"description":"Filter criteria object containing the actual filter conditions"}],"intent":"Create a new filter","guidance":["save a filter view ({ name, entity, filters, is_project_level })","Create a new saved filter for test cases, test plans, or test runs in a project"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"]},{"method":"POST","path":"/api/v1/projects","mode":"write","entity":"project","body":[{"name":"name","type":"string","required":true,"json_path":"/project/name"},{"name":"description","type":"string","json_path":"/project/description"}],"intent":"Create a new project.","guidance":["Pinned to human approval","Create a new project with name and description"],"returns":["name","description"]},{"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},{"name":"report_type","type":"string","required":true,"values":["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","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_statu"},{"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 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`"},{"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;\n`custom_report` is a user-configured one. For a Requirement Traceability\nreport, `system_generated` makes the server auto-populate\n`report_filters.requirements` with every requirement in the project."},{"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"},{"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":"Integer run ids \u2014 pairs with report_timeframe=specific_test_runs.","json_path":"/report_filters/test_runs"},{"name":"test_plans","type":"array","description":"Integer plan ids \u2014 pairs with report_timeframe=specific_test_plans.","json_path":"/report_filters/test_plans"},{"name":"test_cases","type":"string","description":"Case selection \u2014 pairs with report_timeframe=specific_test_cases. FOLDER-KEYED\n(see SelectionData), never a flat id list: the server resolves the selection\nthrough its folder map, so any other shape yields zero cases and saves an\nUNSCOPED, project-wide report at 200.\n\nSend all three keys. Folder ids are STRING keys; test-case ids are INTEGERS\n(the numeric `id`, not the TC-NNN identifier) \u2014 take bo","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":"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.","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 } }.","json_path":"/report_filters/test_plan_selection"},{"name":"builds","type":"array","json_path":"/report_filters/builds"},{"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\n`Test Run Summary`, `Test Run Detailed Report` and `Test Case Activity` \u2014 on\nthe other six report types the server never reads it and drops it silently.\nPersisted (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`."},{"name":"users","type":"array","json_path":"/mail_to/users"},{"name":"external_mails","type":"array","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\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"},{"name":"time","type":"string","example":"08:00","description":"24-hour HH:MM, UTC.","json_path":"/frequency_details/time"},{"name":"day","type":"string","example":"monday","description":"Required for weekly (lowercase day name) and monthly (integer 1-28).\nOmit for daily.","json_path":"/frequency_details/day"}],"intent":"Create a new scheduled report for a project","guidance":["Create a report configuration under the given project"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"]},{"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","description":"Name of the root folder"},{"name":"notes","type":"string","example":"This folder contains all regression test cases","description":"Optional notes or description for the folder"}],"intent":"Create a root-level folder for test cases in a project","guidance":["Create a new root-level folder for organizing test cases within a project"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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},{"name":"folder_id","type":"integer","description":"ID of the shared-field folder to place the shared step in. Null or omitted means the root (Unassigned)."},{"name":"shared_step_details","type":"array","required":true,"fields":[{"name":"order","type":"integer","required":true},{"name":"step","type":"string"},{"name":"result","type":"string"},{"name":"test_data","type":"string"}]}],"intent":"Create a new shared step in a project","guidance":["Create a new shared step within a project"],"returns":["id","title","step_count","test_case_count"]},{"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},{"name":"test_run_id","type":"string","required":true},{"name":"test_case_id","type":"integer","required":true},{"name":"step_id","type":"integer","required":true}],"body":[{"name":"status_id","type":"integer","json_path":"/test_run_step_result/status_id"},{"name":"description","type":"string","json_path":"/test_run_step_result/description"}],"intent":"Record a per-step result for a case in a run (v1).","guidance":["record a per-step outcome for a step-templated case ({ status_id, description })"]},{"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,"description":"Name of the new folder.","json_path":"/folder/name"},{"name":"notes","type":"string","description":"Description of the new folder.","json_path":"/folder/notes"}],"intent":"create subfolder inside a specific folder","guidance":["Create a new subfolder within a specific folder in a project"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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 the FIRST test case in a project that has no folders (needs create_at_root).","guidance":["Narrow-purpose route: the first case in a project that has zero folders","Verified live on a folderless project: 200, and the folder appears","The route and the flag always travel together","The product's UI derives both from one boolean","no folders exist AND the user picked no folder","A correct refusal, not something to retry: list the folders and pick one"],"returns":["result","step"]},{"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,"fields":[{"name":"name","type":"string","required":true},{"name":"test_case_folder_id","type":"string","required":true},{"name":"attachments","type":"array"},{"name":"automation_state","type":"integer"},{"name":"automation_status","type":"string"},{"name":"case_type","type":"integer"},{"name":"custom_fields","type":"object"},{"name":"description","type":"string"},{"name":"estimate","type":"string"},{"name":"estimated_duration_seconds","type":"integer"},{"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"},{"name":"reviewers","type":"array"},{"name":"send_for_review","type":"boolean"},{"name":"shared_precondition_id","type":"integer"},{"name":"status","type":"integer"},{"name":"tags","type":"array"},{"name":"template","type":"string"},{"name":"template_id","type":"integer"},{"name":"template_step_type","type":"string"},{"name":"test_case_dataset","type":"string"},{"name":"test_case_steps","type":"array"}]},{"name":"create_at_root","type":"boolean"}],"intent":"Create test cases for a folder in a project.","guidance":["create a case in a folder","body wrapped in test_case, name required","Create one or more test cases within a specific folder in a project","If create_at_root is true, the test case will be created at the root of the project"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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","json_path":"/test_plan/name"},{"name":"description","type":"string","json_path":"/test_plan/description"},{"name":"start_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/start_date"},{"name":"end_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/end_date"},{"name":"plan_status","type":"string","description":"Plan status. The list filter accepts new / started / completed.","json_path":"/test_plan/plan_status"},{"name":"owner","type":"integer","description":"Owner user id \u2014 resolve via the project users read first.","json_path":"/test_plan/owner"},{"name":"parent_plan_id","type":"integer","description":"Parent plan's INTEGER id. Set it to create (or re-parent into) a SUB-plan \u2014 the\nresult carries an STP-NNN identifier. Null/omitted means a top-level plan.","json_path":"/test_plan/parent_plan_id"},{"name":"tags","type":"array","json_path":"/test_plan/tags"},{"name":"reviewers","type":"array","description":"Reviewer user ids.","json_path":"/test_plan/reviewers"},{"name":"attachments","type":"array","description":"Attachment ids already uploaded via the attachments surface.","json_path":"/test_plan/attachments"},{"name":"custom_fields","type":"object","json_path":"/test_plan/custom_fields"},{"name":"issues","type":"array","description":"Linked tracker issues. Structured \u2014 NOT a flat array of keys.","fields":[{"name":"issue_id","type":"string"},{"name":"issue_type","type":"string"},{"name":"metadata","type":"object"}],"json_path":"/test_plan/issues"},{"name":"test_runs","type":"string","description":"The plan's linked test runs. Accepts EITHER an array of test-run integer ids, OR a\nbulk-selection object for \"every run matching this filter\". On update this REPLACES\nthe existing link set rather than appending.","json_path":"/test_plan/test_runs"}],"intent":"Create a test plan \u2014 or a SUB-plan, by setting parent_plan_id","guidance":["body wrapped in test_plan","Create a test plan in the project","Everything goes under the test_plan key","test_runs accepts two shapes","Both are honoured server-side","an unknown option is rejected"]},{"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"},{"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":["log a NEW result for a case in a run","Create a new test result for a specific test case within a test run in a project"],"returns":["id","status","backtrace","configuration_id","created_at","created_by_imported","custom_fields","description","error_description","expanded","failure_type","finished_at"]},{"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],"description":"Explicit case ids to pin into the run. Top level, NOT inside `test_run`. Use this or `selection`."},{"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":["Create a new test run for a specific project, which can be used to execute test cases"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"dataset_id","type":"integer","required":true},{"name":"option_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"Delete one option (POST to /delete) \u2014 DESTRUCTIVE, drops values using it.","guidance":["destroys the values that used it","Removing an option deletes the stored values that used it, across every project linked to the dataset","that preserves the values"]},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"Delete a dataset (POST to /delete) \u2014 DESTRUCTIVE, drops its options and values.","guidance":["drops its options and the values the linked projects stored","Removes the option set and unlinks its projects, destroying the values those projects had stored for this field","Name the affected projects before calling"]},{"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}],"intent":"Delete a field definition (POST to /delete) \u2014 DESTRUCTIVE, cascades to stored values.","guidance":["destroys every stored value in every linked project","Name the projects first","Removes the definition and every value stored for it, in every project it is linked to","There is no bin and no undo","Before calling: read the field, say how many projects it is linked to, and name them","If the user only wants it hidden rather than destroyed, that is a different ask"]},{"method":"DELETE","path":"/api/v1/projects/{project_id}/datasets/{uuid}","mode":"destructive","entity":"dataset","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"uuid","type":"string","required":true}],"intent":"Delete a dataset.","guidance":["say it isn't available rather than calling it, and don't substitute by parsing the file yourself","A 422 here means NOT-ENTITLED, not a bad payload","say so and stop, don't retry with a different body","BINDING shape is DIFFERENT from the dataset shape, and this is the usual failure","the dataset's uuid repeated on every column it owns","The binding object has NO top-level uuid or name (both stripped by strong params)"]},{"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":"Delete an exploratory session","guidance":["Deletes an exploratory session"]},{"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":"Delete a session log entry","guidance":["Permanently deletes a log entry from the specified exploratory session"]},{"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 filter","guidance":["entity = testcase | testplan | testrun | exploratory_session","filter_id is an integer","reuse a saved view's filters as the q for a later search or bulk action"]},{"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":"Delete a folder and its contents","guidance":["Deletes a folder by its ID and optionally returns the parent folder's path hierarchy"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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"}],"intent":"Delete a report (this removes the report itself, not just its schedule)","guidance":["returns the remaining reports","merely stopping a report from recurring","The response is the list of remaining reports","There is no bin and no undo","so confirm the report's name with the user first"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"]},{"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","guidance":["affects every test case embedding it","Deletes a shared step from a project"]},{"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":"Delete an attachment from a test case in a folder in a project","guidance":["read the file from that url"],"returns":["id","byte_size","content_type","filename"]},{"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":"Delete a test plan (or sub-plan) \u2014 no bin, no undo","guidance":["The server performs no plan-type check","so this deletes a sub-plan by its own integer id exactly the same way","Deleting a parent plan is destructive to its children"]},{"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 test result","guidance":["Prefer PATCHing the latest result to correct a mistake","Deleting a result removes an execution record","the case's latest status is recomputed from what remains"]},{"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"}],"intent":"delete test run","guidance":["The discovered ids must be CARRIED INTO the create body","discovery alone puts nothing in the run","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","It is validated before the selection","so a bare 400 'invalid data' means a bad test_run field"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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},{"name":"duplicate_id","type":"integer","required":true}],"body":[{"name":"discard_reason","type":"string","description":"Short reason code / label for why this isn't a duplicate."},{"name":"user_feedback","type":"string","description":"Free-text feedback from the user."}],"intent":"Discard a duplicate suggestion \u2014 dismisses it WITHOUT touching test cases","guidance":["DISCARD a suggestion","dismisses it WITHOUT touching either test case","The safe default when the user says it's not a duplicate or is unsure","Mark a recommendation as discarded","This is the safe way to dismiss a suggestion: it changes only the recommendation's status to discarded and leaves both test cases exactly as they are","discard_reason and user_feedback are optional but feed the dedupe model"]},{"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":"Desired export format. Note this is a STRING here, while the same-named field\non `send_email_now` is an ARRAY."},{"name":"required","type":"object","description":"Optional column selection, honoured only for `Test Run Detailed Report`\n(ignored for every other type). Shape:\n`{ \"\": { \"fields\": [\"id\",\"title\",...] } }`. Omit to get the report's\ndefault columns."}],"intent":"Initiate a download of a report in a specified file format","guidance":["Request generation and download channel for the specified report"],"returns":["channel"]},{"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"},{"name":"custom_fields","type":"object","example":{"123":"option_value","456":["multi","select"]},"json_path":"/test_case/custom_fields"},{"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"},{"name":"preconditions","type":"string","json_path":"/test_case/preconditions"},{"name":"priority","type":"integer","example":483,"json_path":"/test_case/priority"},{"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\".","json_path":"/test_case/reviewers"},{"name":"status","type":"integer","example":496,"json_path":"/test_case/status"},{"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":"string","description":"Dataset binding for a data-driven case. On this path it is applied only when `is_dataset_modified` is true.","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"}],"intent":"Partially edit a test case (v2)","guidance":["PREFERRED partial update","send ONLY changed fields","Partially update the details of a test case within a specific folder in a project","it is the authoritative list","send test_case_steps instead - estimate is accepted and then dropped","estimated_duration_seconds is the one that persists"],"returns":["result","step"]},{"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"},{"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"},{"name":"custom_fields","type":"object","json_path":"/test_case/custom_fields"},{"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"},{"name":"name","type":"string","json_path":"/test_case/name"},{"name":"owner","type":"integer","json_path":"/test_case/owner"},{"name":"preconditions","type":"string","json_path":"/test_case/preconditions"},{"name":"priority","type":"integer","json_path":"/test_case/priority"},{"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"},{"name":"tags","type":"array","json_path":"/test_case/tags"},{"name":"template","type":"string","json_path":"/test_case/template"},{"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":"string","description":"Dataset binding for a data-driven case. On create the mappings are always regenerated from this, so `is_dataset_modified` is ignored here.","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","json_path":"/test_case/test_case_steps"}],"intent":"Edit a test case","guidance":["Update the details of a test case within a specific folder in a project","Omitted fields are left untouched EXCEPT template, template_id and review_status, which are reset to defaults"],"returns":["result","step"]},{"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"}],"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],"description":"Explicit case ids to pin into the run. Top level, NOT inside `test_run`. Use this or `selection`."},{"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":"Edit Test Run","guidance":["Update the details of a specific test run within a project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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","pdf","excel"],"example":"csv","description":"Export file format"},{"name":"start_date","type":"string","example":"2025-01-01","json_path":"/filters/start_date"},{"name":"end_date","type":"string","example":"2025-12-31","json_path":"/filters/end_date"},{"name":"status","type":"array","example":["passed","failed"],"json_path":"/filters/status"}],"intent":"Export dashboard analytics data","guidance":["Initiates an asynchronous export of dashboard analytics data in the specified format","The export is processed via background job and returns an export ID for tracking","Async Processing: Data export runs via Sidekiq ExportDashboardWorker::FileExportWorker","Supported Formats: CSV, PDF, Excel (based on type parameter)"],"returns":["export_id"]},{"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":"Get active test run result statistics","guidance":["Retrieve statistics of active test runs for a specific project"],"shape":"discovered"},{"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":"Get archived test cases.","guidance":["Retrieve a list of archived test cases in a specific project"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"Count archived test plans","guidance":["count archived plans","Returns {success, count}","the number of archived plans in the project"],"shape":"discovered"},{"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 automation stats analytics","guidance":["Retrieve automation statistics for a specific project"],"returns":["automated_coverage","automated_test_cases","empty_data","manual_test_cases","total_test_cases"]},{"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":"Get all the closed test runs for a project","guidance":["Retrieve a list of all closed test runs for a specific project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"Get closed test run analytics","guidance":["Retrieve statistics of closed test runs for a specific project"],"returns":["month"]},{"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":"Get closed test runs split analytics","guidance":["Retrieve split statistics of closed test runs for a specific project"],"returns":["name"]},{"method":"GET","path":"/api/v1/projects/{project_id}/configurations","mode":"read","entity":"configuration","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Get configurations for a project","guidance":["Retrieve a list of configurations for a specific project"],"returns":["id","name","created_at","group_id","is_suggested","record_status"]},{"method":"GET","path":"/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets/{dataset_id}","mode":"read","entity":"custom_field","path_params":[{"name":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"Read one dataset \u2014 its project links and option set.","guidance":["read one dataset's project links and options"],"shape":"discovered"},{"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}],"intent":"Read one custom field definition, with its datasets and project links.","guidance":["read one definition + its datasets and linked projects","do this BEFORE any update, which is a full replace","Options are NOT inline","Datasets come back WITHOUT their options inline","Read this before any update","so you need the current values to avoid clearing them"],"shape":"discovered"},{"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},{"name":"entity_type","type":"string","required":true,"values":["test-case","test-result","test-plan"]},{"name":"field_id","type":"string","required":true}],"query":[{"name":"q","type":"string"}],"intent":"Get the allowed values/options of one custom field.","guidance":["If nothing matches, the field doesn't exist in that workspace","say so rather than guessing a field id","Multi-dropdowns need OPTION IDS, not labels","That legacy family writes a table local to the Rails app that is DEPRECATED and that nothing reads","It is blocked at the gate","testhub owns definitions"],"shape":"discovered","paginated":true},{"method":"GET","path":"/api/v1/projects/{project_id}/datasets/{uuid}","mode":"read","entity":"dataset","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"uuid","type":"string","required":true}],"intent":"Get a specific dataset.","guidance":["read one dataset by UUID","Retrieve a specific dataset by its UUID including all variables and rows"],"returns":["name","created_at","rows_count","uuid"]},{"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},{"name":"uuid","type":"string","required":true}],"intent":"Get test cases linked to a dataset","guidance":["list the test cases bound to this dataset (paged p)","Returns the test cases linked to a dataset, identified by its UUID"],"shape":"discovered","paginated":true},{"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"}],"intent":"Get dataset variables/columns.","guidance":["BINDING shape is DIFFERENT from the dataset shape, and this is the usual failure","the dataset's uuid repeated on every column it owns","The binding object has NO top-level uuid or name (both stripped by strong params)","so do NOT copy the dataset's read shape into it","Never report a dataset as linked from the 200 alone","A dataset is addressed by a UUID (the dataset path param), NOT an integer"],"returns":["name","uuid"],"paginated":true},{"method":"GET","path":"/api/v1/projects/{project_id}/ai-duplicates/status","mode":"read","entity":"duplicate","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Dedupe job status \u2014 use this to tell \"feature off\" from \"no duplicates\"","guidance":["ALWAYS call this when the list is empty","Status of the project's most recent dedupe run"],"returns":["status"]},{"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},{"name":"duplicate_id","type":"integer","required":true}],"intent":"Read the pre-merge snapshot of a MERGED duplicate's source test case","guidance":["after a merge, read the pre-merge snapshot of the case that was folded away","After a merge, the source case no longer exists independently","this returns the snapshot captured at merge time","the only way to show the user what was folded away","Only works for status merged (404 otherwise), and the snapshot may legitimately be absent, which also surfaces as a 404 with error: \"Source test case snapshot not available\"","Treat that as \"not retained\", not as an error to retry"],"shape":"discovered"},{"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},{"name":"duplicate_id","type":"integer","required":true}],"intent":"Read one suggested duplicate, with its test cases resolved","guidance":["read one suggestion with its test cases resolved","do this BEFORE merging so the user can see what would be destroyed","Only status=suggested is readable","Full detail for one recommendation, including the actual test cases it links","so you can show the user what would be merged","Only status suggested is readable here"],"shape":"discovered"},{"method":"GET","path":"/api/v1/projects/{project_id}/{entity}/filter-details","mode":"read","entity":"project","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"entity","type":"string","required":true,"values":["test-cases","trtc","test-runs"],"example":"test-cases"}],"query":[{"name":"q[query]","type":"string","example":"login functionality"},{"name":"q[folder_ids]","type":"string","example":"3306878,3669741,5422801,5422802"},{"name":"q[status]","type":"string","example":"9319,9321"},{"name":"q[priority]","type":"string","example":"550376,9261"},{"name":"q[automation_state]","type":"string","example":"18172471,18172473"},{"name":"q[case_type]","type":"string","example":"9379,9375"},{"name":"q[owner]","type":"string","example":"user123,user456"},{"name":"q[assignee]","type":"string","example":"user789,user101"},{"name":"q[tags]","type":"string","example":"regression,smoke"},{"name":"q[created_at]","type":"string","example":"2024-01-01..2024-12-31"},{"name":"q[updated_at]","type":"string","example":"2024-01-01..2024-12-31"}],"intent":"Get filter details for entity","guidance":["Retrieve filter details for a specific entity type (test-cases, trtc, or test-runs) within a project"],"returns":["id","colour","entity_type","field_name","field_value","internal_name"]},{"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":"Get an exploratory session","guidance":["read one session by INTEGER id","Returns a single exploratory session by its ID"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"]},{"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"]},{"name":"start_date","type":"string"},{"name":"end_date","type":"string"},{"name":"session_ids","type":"string"}],"intent":"Exploratory Session Summary \u2014 live view, no saved report needed","guidance":["exploratory-session summary WITHOUT creating a report","Computes an exploratory-session summary on demand","there is no Schedule row behind it","Use it to answer \"summarise our exploratory sessions\" without creating a report first","Returns session counts, bugs found, top testers (resolved to user objects) and the session list"],"shape":"discovered"},{"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":"Get filter details","guidance":["Retrieve details of a specific saved filter by its ID","It does NOT validate or strip invalid custom-field references","a filter saved with a non-existent custom-field id is returned unchanged","A missing or deleted filter comes back as 400 {\"success\": false, \"message\": \"Filter not found\"}, not 404"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"]},{"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":"Get summary of entities to be deleted when removing a folder","guidance":["PREVIEW what deleting a folder will remove before calling rm","Provides a summary of all entities (test cases, recordings, sub-folders) that would be deleted if the folder is removed"],"returns":["active_test_case_count","archived_test_case_count","sub_folders","test_recordings_count"]},{"method":"GET","path":"/api/v1/projects/{project_id}/repository/mapping","mode":"read","entity":"folder","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Get the project's folder tree (v1).","guidance":["the whole project folder TREE in one call (structure array)"],"shape":"discovered"},{"method":"GET","path":"/api/v1/group/users-v2","mode":"read","entity":"user","query":[{"name":"q","type":"string"}],"intent":"Get group users V2 with pagination","guidance":["Retrieve a paginated list of users in the group with search and filtering capabilities, WITHOUT project scoping","used by the Global Search filter dropdowns","Search by user IDs (comma-separated) or by a name string"],"returns":["id","browserstack_user_id","email","full_name","group_id","onboarded","reports_and_notification_enabled"]},{"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":"Get issues count analytics","guidance":["Retrieve the count of issues for a specific project"],"returns":["label"]},{"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","guidance":["read ONE project by its INTEGER id","so for an integer id this is the right read","Do NOT translate first just to fetch the project","It serves two jobs at once: (1) READ","returns the project's full detail (name, description, permissions, \u2026)","In other words it is NOT translation-only"],"returns":["id","identifier","name","created_at","description","jira_mapped","starred","test_cases_count","test_plans_count","test_runs_count","thProjectId","user_role"]},{"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}],"intent":"Get project-specific custom fields V2 for test cases","guidance":["START HERE for CUSTOM fields","Does NOT expose system-field option ids","Retrieve custom fields, default fields, and system fields for test cases in a specific project (V2 format)"],"returns":["id","default_value","entity_type","field_name","field_type","group_id","is_bulk_editable","is_filterable","is_required","linked_projects_count","place_holder_text","system_name"]},{"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"]}],"intent":"Get project settings","guidance":["Returns an array of enabled setting keys"],"returns":["in_review_count","test_plan_in_review_count"]},{"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"}],"intent":"Get project users V2 with pagination","guidance":["Retrieve paginated list of users in the group with search and filtering capabilities","Search by user IDs (comma-separated) or by name string"],"returns":["id","browserstack_user_id","email","full_name","group_id","onboarded","reports_and_notification_enabled"]},{"method":"GET","path":"/api/v1/projects/basic","mode":"read","entity":"project","query":[{"name":"q[query]","type":"string","example":"nishchay3"},{"name":"q[identifier]","type":"string","example":"PR-60863"}],"intent":"Get paginated projects (lean payload) \u2014 use this to list or count projects","guidance":["LIST or COUNT projects","so you can reach a true total without truncating","Those two are the only recognised keys"],"returns":["id","name","description","import_id","normalisedName","starred","thProjectId"],"paginated":true},{"method":"GET","path":"/api/v1/projects/minify","mode":"read","entity":"project","intent":"Get all projects (minified dropdown) \u2014 has NO name search","guidance":["never use it to resolve a named project or to count them","Minified list of active projects, for dropdown-style selection","It accepts no q in any form","the backend call has no query option","so it can only page through everything","Do NOT use it to resolve a project the user named"],"returns":["id","name","description","import_id","normalisedName","starred","thProjectId"],"paginated":true},{"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},{"name":"attachment_id","type":"integer","required":true}],"intent":"Get download URL for a report attachment","guidance":["Retrieve a secure URL to download a specific report attachment"],"returns":["url"]},{"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"},{"name":"report_data_only","type":"boolean"},{"name":"today","type":"string","example":"2025-05-13"}],"intent":"Retrieve a scheduled report's config and data \u2014 the general report read","guidance":["works for every report type","Full configuration plus computed data for a report of ANY type","request the section and look at the result","which is a real finding","Pass sections to compute the sections you need","several in ONE call, cheaper than one request per section"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"],"paginated":true},{"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"}],"query":[{"name":"widget_type","type":"string"},{"name":"segment","type":"string"}],"intent":"Fetch one report widget/section \u2014 works for EVERY report type.","guidance":["Section names are validated per type","*_drilldown sections return paginated ROWS, not aggregates","Returns a single section's data for any report type","This is the general section read","so when you need SEVERAL sections, prefer that form with a comma-separated sections list and take them in one call rather than one request per section","Most sections return the computed aggregate for a widget"],"shape":"discovered","paginated":true},{"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","requirement_traceability_report"]}],"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":"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`"},{"name":"sections","type":"string","example":"test_run_data,test_run_status"},{"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"]},{"name":"segment","type":"string"}],"intent":"Get summary statistics for a scheduled report \u2014 run and traceability types ONLY","guidance":["400s on every other type","so not for Test Case Activity or Test Plan Summary","Summary statistics for a scheduled report, for THREE report types only","including every other type in ReportType","This is not the general report-read","optionally with sections"],"shape":"discovered","paginated":true},{"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 of the folder to search for"},{"name":"include_folder_hierarchy","type":"boolean","description":"Whether to include folder hierarchy in the response"}],"intent":"Get all folders and subfolders present in the projects.","guidance":["list root folders (paged with p","Returns all folders and subfolders in a project if it exists in TestHub"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"],"paginated":true},{"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":"Get already selected testcases for a tracebility report","guidance":["Retrieve the already selected testcases for a tracebility report"],"returns":["select_all","unique_test_case_count"]},{"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":"paginated","type":"boolean"}],"intent":"Get shared steps","guidance":["read one shared step with its ordered step details","Retrieves a list of shared steps for a project"],"returns":["id","title"],"paginated":true},{"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":"q[query]","type":"string"},{"name":"pre_fetch","type":"boolean"}],"intent":"Get all shared steps for a project","guidance":["Returns a list of all shared steps within a project"],"returns":["id","title","step_count","test_case_count"],"paginated":true},{"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"}],"intent":"THE source for a system field's option ids (priority/status/case_type/automation_state).","guidance":["a single attachment-URL field is enough to push the response past the ~30 KB cap","Supports q to search the option list and p to page it, which matters","a workspace can accumulate dozens of options on a single field"],"shape":"discovered","paginated":true},{"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":"Get all attachments for a test case in a folder in a project","guidance":["list a case's attachments","Retrieve all attachments associated with a specific test case within a folder","CURRENTLY RETURNING 500","Verified live against five different real test cases, with both the case's true folder_id and a mismatched one","a reproducible server error, not a bad request","Do NOT retry it and do not report it to the user as their mistake"],"returns":["id","byte_size","content_type","filename"]},{"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":"Get a test case by INTEGER id (v1, folder-scoped) \u2014 full detail + its TC-NNN identifier","guidance":["READ one case by INTEGER id (folder-scoped)","Read a test case by its INTEGER id","so for an integer id this v1 read is the ONLY way to fetch the case","Two jobs at once: (1) READ","NOT translation-only","Don't fall back to listing the folder to find the case: if you have the integer id, read it here directly"],"returns":["id","identifier","title"]},{"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":"Get test case count trend analytics","guidance":["Retrieve the trend of test case counts for a specific project"],"returns":["field"]},{"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","example":"folder_path"}],"intent":"Get Test Case Details","guidance":["full detail (steps, custom fields, issues) before editing","read this, improve, then write back"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"Get test case histories","guidance":["list all version records for a test case","Retrieve all history records for a specific test case"],"returns":["id","comment","created_at","entity_id","entity_type","group_id","project_id","source","user_id","version_id","version_name"]},{"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}],"intent":"Get test case history diff","guidance":["compare two versions","versions[] with exactly two ids (PAID plan)","Retrieve the diff of a specific history record for a test case"],"returns":["id","order","result","shared_step_id","step"]},{"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":"Get a specific test case history","guidance":["Retrieve details of a specific test case history by its ID"],"returns":["id","comment","created_at","entity_id","entity_type","group_id","project_id","source","user_id","version_id","version_name"]},{"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":"Get tags and metadata for a test case","guidance":["read a case's current tags","Retrieve the tags and metadata associated with a specific test case in a project"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"Get test case type split analytics","guidance":["Retrieve the split of test case types for a specific project"],"returns":["name","field","value","y"]},{"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"}],"query":[{"name":"required[fields]","type":"string","values":["count"]}],"intent":"Get paginated test cases for a test run","guidance":["page the cases in a run (each row is the case you log results against)","Retrieve a paginated list of test cases for a specific test run in a project"],"shape":"discovered"},{"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":"required[fields]","type":"string","example":"owner,priority"},{"name":"required[custom_fields]","type":"string","example":"121,119"},{"name":"all_folders","type":"string","values":["true"],"example":"true"}],"intent":"List a project's test cases \u2014 REQUIRES all_folders=true to be project-wide.","guidance":["Without all_folders=true this returns only ONE folder's cases","the project's first root folder","So a project-wide question answered from a bare call silently under-counts by whatever sits in every other folder, and nothing in the response says so","all_folders is compared as the STRING 'true' (all_folders = params['all_folders'] == 'true')","A JSON boolean true, 1, TRUE or any other value all fail that check and silently fall back to the single-folder scope"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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":"Get a test plan by INTEGER id (v1) \u2014 full detail + its TP-NNN identifier","guidance":["READ one plan by INTEGER id","Read a test plan by its INTEGER id (project integer id in the path)","Don't translate first just to read the plan","this returns its full detail directly","Two jobs at once: (1) READ","the plan's full detail (under test_plan"],"returns":["identifier","name"]},{"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"},{"name":"test_run_id","type":"integer"},{"name":"today","type":"string"}],"intent":"Execution-trend chart data for ONE plan or ONE run (XOR)","guidance":["execution-trend chart data for ONE plan or ONE run","pass test_plan_id XOR test_run_id (both or neither is a 400)","Time-series execution trend backing the Insights tab chart","Scope it with exactly one of test_plan_id or test_run_id","Passing both returns 400 \"Provide either test_plan_id or test_run_id, not both\"","passing neither returns 400 \"test_plan_id or test_run_id is required\""],"shape":"discovered"},{"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 a test plan","guidance":["chart data for the exploratory sessions linked to a plan","Aggregated chart data for exploratory sessions linked to the plan","the other half of the Insights tab","Like execution-trend, this is chart data only","insights widgets themselves are not a CRUD resource here"],"shape":"discovered"},{"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":"include_sub_test_plan_runs","type":"boolean"},{"name":"compact","type":"boolean"},{"name":"is_archived","type":"boolean"}],"intent":"List the test runs linked to a test plan","guidance":["list the runs a plan groups","Paginated list of the runs a plan groups","that field replaces the link set rather than appending to it","include_sub_test_plan_runs=true also returns runs belonging to the plan's sub-plans"],"shape":"discovered","paginated":true},{"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}],"intent":"Get paginated custom fields for test results","guidance":["Retrieve paginated list of custom fields configured for test results in a project"],"returns":["id","default_value","entity_type","field_name","field_type","field_user_name","is_bulk_editable","is_filterable","is_required","optional","placeholder"],"paginated":true},{"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"},{"name":"test_case_id","type":"integer","required":true}],"intent":"Get test results for a test case in a test run","guidance":["read a case's results within a run","Retrieve a list of test results for a specific test case within a test run in a project"],"returns":["id","status","backtrace","configuration_id","created_at","created_by_imported","custom_fields","description","error_description","expanded","failure_type","finished_at"]},{"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":["READ a run by INTEGER id","Read a test run by its INTEGER id (with the project's integer id in the path)","Don't translate first just to read the run","this returns its full detail directly","Two jobs at once: (1) READ","NOT translation-only"],"returns":["id","identifier","name","uuid"]},{"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},{"name":"test_run_id","type":"string","required":true}],"intent":"Get a run with its per-case rows (v1).","guidance":["the run with its per-case rows (did it pass?)"],"shape":"discovered"},{"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"}],"intent":"Get custom field values for test results in test runs","guidance":["Retrieve default field values and optionally custom fields for test results (TRTC - Test Run Test Case)"],"returns":["id","applies_to_all_projects","default_value","entity_type","field_name","field_type","is_required","link_to_future_projects","place_holder_text"]},{"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.","json_path":"/selection/select_all"},{"name":"unique_test_case_count","type":"integer","example":2,"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.","json_path":"/selection/folders"},{"name":"shared_selection","type":"object"}],"intent":"get selected test runs of a project","guidance":["Retrieve selected test runs for a specific project based on the provided selection criteria"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"ignore_run_type","type":"boolean"},{"name":"include_test_plan","type":"boolean"}],"intent":"Get all the test runs for a project","guidance":["list the project's (open) runs, paged with p","Retrieve a list of all test runs for a specific project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"Import a CSV dataset \u2014 NOT SUPPORTED in this profile","guidance":["NOT SUPPORTED in this profile","say CSV import isn't available","CSV import is not supported here","If asked to import a dataset from CSV, say it isn't available and point to the Test Management UI"]},{"method":"GET","path":"/api/v1/activities","mode":"read","entity":"activity","query":[{"name":"project_id","type":"integer"},{"name":"cursor","type":"string"},{"name":"limit","type":"integer"},{"name":"actor_id","type":"integer"},{"name":"starred_only","type":"boolean"},{"name":"entity_type","type":"array"}],"intent":"Read the activity feed (audit trail) \u2014 CURSOR paged, 15-day window","guidance":["WHO changed WHAT recently","group-wide audit trail","Group-wide audit trail of who changed what","Read-only: activity events cannot be created, edited, or deleted","Two things differ from every other list in this profile: - Cursor pagination, not page numbers","so you cannot jump to a page or read a total"],"returns":["no_starred_projects"]},{"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}],"intent":"List archived test plans","guidance":["where a 'missing' plan usually is, and the source of ids for bulk-retrieve","Paginated list of the project's archived plans","so if a plan the user names seems missing, look here before concluding it was deleted"],"shape":"discovered","paginated":true},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"List a dataset's options with their ids.","guidance":["list a dataset's options WITH their ids","the ids a filter or a test-case write needs"],"shape":"discovered"},{"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"},{"name":"q[uuid]","type":"string"}],"intent":"List datasets for a project.","guidance":["list datasets (paged p","Retrieve a paginated list of datasets for a project with optional filtering by name or UUID"],"returns":["name","created_at","rows_count","test_cases_count","updated_at","uuid"],"paginated":true},{"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":"entity_type","type":"string"},{"name":"entity_id","type":"integer"},{"name":"duplicates","type":"string"}],"intent":"List AI-suggested duplicate test cases \u2014 an empty list may mean the feature is OFF","guidance":["LIST the duplicate suggestions awaiting review","An EMPTY list may mean the feature is off","List the AI dedupe recommendations awaiting review in a project","Only duplicates with status suggested are returned","once merged, archived, or discarded they leave this list","An empty result is ambiguous"],"shape":"discovered","paginated":true},{"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}],"intent":"List defects for an exploratory session","guidance":["List defaults to active sessions","the parent project takes the INTEGER project id (v1)","defects are the issues linked to the session"],"returns":["issue_id","issue_type"],"paginated":true},{"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}],"intent":"List logs for an exploratory session","guidance":["page the session's log entries","Returns a paginated list of log entries for the specified exploratory session"],"returns":["id","status","content","created_at","elapsed_time","session_id","updated_at"],"paginated":true},{"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":"q[session_state]","type":"string","values":["active","closed"],"example":"active"},{"name":"q[assignee]","type":"integer","example":42},{"name":"q[owner]","type":"integer","example":42},{"name":"q[created_by]","type":"integer","example":10},{"name":"q[created_at_from]","type":"string","example":"2026-01-01"},{"name":"q[created_at_to]","type":"string","example":"2026-01-31"},{"name":"q[tags][]","type":"array","example":["regression","payment"]},{"name":"q[configuration_ids][]","type":"array","example":[1,2]},{"name":"q[search]","type":"string","example":"checkout"},{"name":"q[status]","type":"string","example":"pass"}],"intent":"List exploratory sessions for a project","guidance":["list sessions (defaults to active","Returns a paginated list of exploratory sessions"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"],"paginated":true},{"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","type":"string","required":true,"values":["testcase","testplan","testrun","exploratory_session","test_plan_test_case"]}],"intent":"List filters","guidance":["list saved filter views (optional entity = testcase|testplan|testrun|exploratory_session)","Retrieve a paginated list of saved filters for a project, optionally filtered by entity type"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"],"paginated":true},{"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":"List subfolders and folder metadata for a specific folder in a project","guidance":["one folder's detail + its direct subfolders + ancestors","Retrieve a list of subfolders and their metadata for a specific folder in a project"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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":"required[fields]","type":"string","example":"owner,priority"},{"name":"required[custom_fields]","type":"string","example":"121,119"}],"intent":"List the test cases in one folder","guidance":["Page through the cases directly inside a folder, by the folder's INTEGER id","Use this when the user has already named or selected a folder","Rows are verbose and list reads truncate around 30 KB"],"shape":"discovered","paginated":true},{"method":"GET","path":"/api/v1/projects","mode":"read","entity":"project","query":[{"name":"q","type":"string","example":"nishchay3"},{"name":"sort_by","type":"string"},{"name":"starred","type":"boolean"},{"name":"shared","type":"boolean"},{"name":"is_hidden_projects","type":"boolean","example":true}],"intent":"List projects for a group.","guidance":["Paginated list of the group's projects","query and page are not read by the server","so use this to FIND a project, not to enumerate them"],"returns":["id","identifier","name","created_at","description","import_id","project_creator","test_cases_count","test_runs_count"],"paginated":true},{"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"},{"name":"q[scheduled]","type":"string","values":["true","false"]}],"intent":"List all the scheduled reports","guidance":["list the project's report schedules (paged p","Retrieve a paginated list of schedules"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"],"paginated":true},{"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":"q","type":"string"}],"intent":"List test-case tags (paginated).","guidance":["list a project's test-case tags (paged with p, optional q)"],"shape":"discovered","paginated":true},{"method":"GET","path":"/api/v1/admin-v2/settings/templates","mode":"read","entity":"","query":[{"name":"entity_type","type":"string","values":["TestCase","TestResult","TestPlan","ExploratorySession"]},{"name":"project","type":"integer"},{"name":"name","type":"string"}],"intent":"List the workspace's test-case templates \u2014 THE source for `template_id`.","guidance":["Ids are per-workspace","so one carried over from another workspace (or from an example) produces the generic 400 \"The API request is invalid or improperly formed\" on a test-case create, with nothing naming the offending field","Call this when the user asked for a named template, or to confirm a template id before reusing one","One call therefore yields both fields","NOTE THE CASING: entity_type here is CamelCase (TestCase), unlike the hyphenated test-case used in path segments elsewhere","Passing test-case returns an empty list with a 200"],"shape":"discovered","paginated":true},{"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":"include_sub_plans","type":"boolean"},{"name":"status","type":"string","values":["new","started","completed"]},{"name":"sort_by","type":"string"},{"name":"minify","type":"boolean"}],"intent":"List test plans in a project (optionally including sub-plans)","guidance":["include_sub_plans=true to see sub-plans too","Paginated list of the project's test plans","This is how you find a plan's integer id before reading, updating, or deleting it","so never try to find a plan through search","Without it you see only top-level plans and a plan's children are invisible"],"shape":"discovered","paginated":true},{"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"]},{"name":"field_source","type":"string","values":["system","custom","all"]},{"name":"field_type","type":"string"},{"name":"q","type":"string"}],"intent":"THE canonical workspace field list (system + custom) \u2014 start here for definitions.","guidance":["workspace-wide list of DEFINITIONS across all projects","'does a field called X exist anywhere?'","The authoritative list (testhub-backed)","This is the authoritative definition list: it is backed by testhub, which owns custom-field definitions","That legacy collection reads a DEPRECATED table local to the Rails app that nothing else consults","its contents are unrelated to the real fields"],"shape":"discovered","paginated":true},{"method":"POST","path":"/api/v1/projects/{project_id}/tags/merge","mode":"write","entity":"tag","path_params":[{"name":"project_id","type":"integer","required":true}],"body":[{"name":"source_id","type":"integer","required":true},{"name":"target_id","type":"integer","required":true},{"name":"entity_type","type":"string","required":true,"values":["test_case","test_run","test_plan","shared_step"]}],"intent":"Merge one tag into another (v1). Admin-gated (tags_management).","guidance":["merge one tag into another ({ source_id, target_id, entity_type })"]},{"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","required":true,"example":123,"description":"ID of the new parent folder (internal APIs)"},{"name":"new_base_project_id","type":"integer","example":456,"description":"Optional destination project ID for cross-project moves"},{"name":"user_action","type":"string","example":"move_folder"}],"intent":"Move a test case folder to a different parent folder","guidance":["move a folder ({ new_base_folder_id, new_base_project_id })","cross-project moves run async","Move a test case folder to a new parent folder within the same project","This operation updates the folder's hierarchy without changing its contents"],"returns":["async","unique_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","required":true,"description":"Name of the new folder.","json_path":"/folder/name"},{"name":"notes","type":"string","description":"Description of the new folder.","json_path":"/folder/notes"}],"intent":"rename a folder in a project","guidance":["Rename an existing folder within a specific project"],"returns":["request_trace_id"]},{"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],"description":"List of folder IDs to reorder"},{"name":"prev","type":"integer","example":101,"description":"ID of the folder that will precede the moved folder"},{"name":"next","type":"integer","example":103,"description":"ID of the folder that will follow the moved folder"}],"intent":"Reorder folders in a project","guidance":["Reorder folders within a project by specifying the current order and optionally the previous and next folder IDs"],"returns":["request_trace_id"]},{"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":"ID of the test case that will be above the moved ones.","json_path":"/re_order/top_test_case"},{"name":"bottom_test_case","type":"string","description":"ID of the test case that will be below the moved ones.","json_path":"/re_order/bottom_test_case"},{"name":"test_cases","type":"array","required":true,"description":"Array of test case IDs to be moved.","json_path":"/re_order/test_cases"}],"intent":"Reorder test cases within a folder of a project","guidance":["reorder cases within a folder","Reorders test cases in a folder by placing them between top_test_case and bottom_test_case"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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. Passed in the BODY, not the path."},{"name":"merge_action","type":"string","required":true,"description":"`merge` folds source into target; any other value archives."},{"name":"source_testcase_id","type":"integer","description":"Required for merge \u2014 the case that will cease to exist independently."},{"name":"target_testcase_id","type":"integer","description":"Required for merge \u2014 the case that survives."}],"intent":"Resolve a duplicate by MERGING or ARCHIVING \u2014 destroys or hides a test case","guidance":["RESOLVE a suggestion","merge_action=merge folds source_testcase_id into target_testcase_id (DESTRUCTIVE to a test case), anything else archives","duplicate_id goes in the BODY","Act on one dedupe recommendation","merge_action selects what happens: - \"merge\"","folds source_testcase_id into target_testcase_id"]},{"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":"Restore a specific history entry","guidance":["roll the case back to this version (PAID plan","Restore a specific history entry by its ID"]},{"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":"duplicates","type":"string","description":"Duplicate-type filter."},{"name":"owner","type":"string","description":"Owner tab \u2014 scopes to the caller's own duplicates vs all."}],"intent":"Filtered search over suggested duplicates","guidance":["filtered search over suggestions (owner tab, duplicate type, test-case attributes)","Subject to the identical flag caveat: an empty list may mean the feature is off rather than that there are no duplicates"],"shape":"discovered","paginated":true},{"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"}],"query":[{"name":"q","type":"string"}],"intent":"Search group tags for an entity type","guidance":["search tags across the whole group (entity_type = test-case|test-run|test-plan)","Retrieve a paginated list of tags for the given entity type across the group (no project scoping)","used by the Global Search filter dropdowns"],"returns":["name","created_at","usage_count"],"paginated":true},{"method":"GET","path":"/api/v1/projects/{project_id}/{entity}/search","mode":"read","entity":"project","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"entity","type":"string","required":true,"values":["test-cases","test-runs","test-plans","reports"]}],"query":[{"name":"q[query]","type":"string","example":"tc"},{"name":"q[folder_id]","type":"integer","example":29529465},{"name":"use_bstack_id","type":"string","values":["0","1"]},{"name":"include_test_plan","type":"boolean"}],"intent":"Search for entities within a project","guidance":["Returns paginated results matching the search criteria"],"shape":"discovered","paginated":true},{"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":"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":["Note: Array values in the q parameter are automatically converted to comma-separated strings for compatibility with existing search logic"],"shape":"discovered","paginated":true},{"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"}],"intent":"Search users with access to a project","guidance":["Retrieves a paginated list of users who have access to the specified project","Returns user details including permissions, feature flags, and product roles"],"returns":["id","browserstack_user_id","customisable_dashboard_accessible","full_name","group_id","has_access","is_build_listing_enabled","is_delighted_visible","is_jira_app_project_panel_visible","is_lcnc_explore_dismissed","is_new_project_settings_visible","is_onboarding_project_header_visible"],"paginated":true},{"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"}],"intent":"Search test case tags","guidance":["Retrieves a paginated list of tags associated with test cases in the project","Returns both simple tag strings and detailed tag objects with metadata"],"returns":["name","created_at","usage_count"],"paginated":true},{"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":"query","type":"string"},{"name":"use_bstack_id","type":"string","values":["0","1"]}],"intent":"Search test cases in a project","guidance":["and see pagination-and-filtering for the key table and each value's source","there is NO top-level values array, and looking for one finds nothing","so it is routinely cut off and appears absent","NEVER probe candidate integers for an id","The four system option fields","a name silently returns 0 because the server matches an id column"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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","example":["qa-leads@company.com"],"description":"Recipient addresses. MUST be an array, even for one address."},{"name":"file_type","type":"array","example":["pdf"],"description":"Formats to attach. MUST be an array. Defaults to [\"pdf\"]."}],"intent":"Email a report immediately (async job).","guidance":["email the report immediately (async job)","Kicks off a background send","a 200 means the job was queued, NOT that mail was delivered","don't report it as sent","Both body fields are ARRAYS and are rejected with 400 \" should be an array\" if sent as scalars","Omitting emails sends to the report's configured recipients"]},{"method":"POST","path":"/api/v1/projects/{project_id}/folder/{folder_id}/undo-rm","mode":"write","entity":"folder","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"folder_id","type":"integer","required":true}],"intent":"Undo folder deletion","guidance":["Restores a previously deleted folder and returns its metadata along with path hierarchy"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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":"dataset_id","type":"integer","required":true},{"name":"option_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"body":[{"name":"option_value","type":"string","required":true,"description":"The option label."},{"name":"is_default","type":"boolean","required":true,"description":"REQUIRED \u2014 a missing value is a 400. Must be 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 parent option this hangs under."}],"intent":"Rename an option or change its default (POST to /update).","guidance":["NON-destructive, stored values follow the option id","Both option_value and is_default are required","a missing is_default is a 400","Renaming an option keeps the stored values pointing at it (the option id is unchanged)","so this is the NON-destructive way to fix a label"]},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"body":[{"name":"link_projects","type":"array","description":"Project INTEGER ids to ADD. Note the spelling \u2014 not `linked_projects`."},{"name":"unlink_projects","type":"array","description":"Project INTEGER ids to REMOVE. Destroys their stored values."},{"name":"link_to_future_projects","type":"boolean"},{"name":"select_all","type":"boolean","description":"Apply to every project; may switch the call to async."}],"intent":"Link or unlink projects for a dataset \u2014 how you change which projects see a field.","guidance":["CHANGE WHICH PROJECTS","Unlinking destroys that project's values","The keys here are link_projects and unlink_projects","Send the wrong spelling and it is silently dropped: 200, nothing changed","This is the single easiest mistake on this surface","These are DELTAS, not the complete new list: send only the projects to add and the projects to remove"]},{"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,"description":"Display label. Editable. REQUIRED even when unchanged."},{"name":"field_type","type":"string","required":true,"description":"REQUIRED for validation, but any change is silently ignored."},{"name":"is_required","type":"boolean","required":true,"description":"REQUIRED \u2014 omitting it is a 400."},{"name":"entity_type","type":"string"},{"name":"place_holder_text","type":"string"},{"name":"default_value","type":"string","description":"Only applied when `field_type` is `boolean`."}],"intent":"Update a field definition's label, requiredness or placeholder (POST to /update).","guidance":["Full replace: field_name + field_type + is_required all required","field_type changes are silently ignored","field_name, field_type and is_required must all be present","Omitting is_required is 400 \"Invalid Params\"","omitting field_name is a 500","field_name IS editable here"]},{"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","description":"Updated name of the dataset.","json_path":"/dataset/name"},{"name":"variables","type":"array","description":"Updated list of dataset variables/columns (max 40).","fields":[{"name":"name","type":"string","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/variables"},{"name":"rows","type":"array","description":"Updated list of dataset rows (max 100).","fields":[{"name":"row_number","type":"integer","required":true},{"name":"row_data","type":"array","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/rows"}],"intent":"Update a dataset.","guidance":["Update an existing dataset by its UUID with new variables and rows"],"returns":["name","created_at","rows_count","uuid"]},{"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 for the session.","json_path":"/exploratory_session/title"},{"name":"description","type":"string","description":"Updated description.","json_path":"/exploratory_session/description"},{"name":"charter","type":"string","description":"Updated testing charter.","json_path":"/exploratory_session/charter"},{"name":"timebox_duration","type":"integer","example":90,"description":"Updated planned duration in minutes.","json_path":"/exploratory_session/timebox_duration"},{"name":"duration","type":"integer","example":45,"description":"Tracked duration in minutes.","json_path":"/exploratory_session/duration"},{"name":"actual_duration","type":"integer","example":50,"description":"Final actual duration in minutes.","json_path":"/exploratory_session/actual_duration"},{"name":"folder_id","type":"integer","example":789,"description":"Move the session to a different folder.","json_path":"/exploratory_session/folder_id"},{"name":"owner","type":"integer","example":55,"description":"TCM user ID of the new owner / assignee.","json_path":"/exploratory_session/owner"},{"name":"assignee","type":"integer","example":55,"description":"Alias for `owner`.","json_path":"/exploratory_session/assignee"},{"name":"tags","type":"array","example":["smoke","payment"],"description":"Replace the session's tags with this list.","json_path":"/exploratory_session/tags"},{"name":"configurations","type":"array","example":[2,4],"description":"Replace the session's configuration IDs.","json_path":"/exploratory_session/configurations"},{"name":"attachments","type":"array","description":"Updated set of blob / media attachment IDs.","json_path":"/exploratory_session/attachments"},{"name":"issues","type":"array","description":"Replace the linked issues for this session.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/exploratory_session/issues"}],"intent":"Update an exploratory session","guidance":["edit a session (title, charter, timebox_duration, duration, owner, tags, ...)","Updates one or more fields of an exploratory session","Time fields (timebox_duration, duration, actual_duration) must be non-negative integers not exceeding 2147483647"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"]},{"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":"Updated rich-text HTML content of the log entry.","json_path":"/session_log/content"},{"name":"status","type":"string","values":["pass","fail","bug","retest","block","note"],"example":"fail","description":"Updated test result status.","json_path":"/session_log/status"},{"name":"elapsed_time","type":"integer","example":180,"description":"Updated elapsed time in seconds.","json_path":"/session_log/elapsed_time"},{"name":"defects","type":"array","description":"Replace the linked defects for this log entry.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/session_log/defects"}],"intent":"Update a session log entry","guidance":["Updates an existing log entry within an exploratory session","Rich-text HTML content is sanitised before storage"],"returns":["id","status","content","created_at","elapsed_time","session_id","updated_at"]},{"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,"description":"Name of the filter"},{"name":"entity","type":"string","required":true,"values":["testcase","testplan","testrun","exploratory_session","test_plan_test_case"],"description":"Entity type the filter applies to. The server's validator accepts five values (the\nlast two were missing here); an unlisted value returns 400 \"Invalid JSON data\"."},{"name":"is_project_level","type":"boolean","description":"Whether this filter is available at project level"},{"name":"filters","type":"object","description":"Filter criteria object containing the actual filter conditions"}],"intent":"Update a filter","guidance":["Update an existing saved filter with new configuration or filter criteria"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"]},{"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"},{"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 these are ids, not names).","json_path":"/test_result/status_id"},{"name":"description","type":"string","description":"Rich text description of the test result.","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's 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":"Update the latest test result","guidance":["update the LATEST result for a case in a run (partial, same body shape)","Update the most recent test result for a specific test case within a test run in a project","Optionally filter by configuration_id or mapping_id"],"returns":["id","name","byte_size","checksum","content_type","download_url","key","product_id","size","url"]},{"method":"POST","path":"/api/v1/projects/{project_id}/settings","mode":"write","entity":"project","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Update project settings","guidance":["Accepts an object with setting keys as properties and boolean values"]},{"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","example":10000},{"name":"report_type","type":"string","required":true,"values":["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","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_statu"},{"name":"title","type":"string","example":"Updated Title"},{"name":"description","type":"string","example":""},{"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 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`"},{"name":"report_creation_mode","type":"string","required":true,"values":["custom_report","system_generated"],"example":"custom_report"},{"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"},{"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":"Integer run ids \u2014 pairs with report_timeframe=specific_test_runs.","json_path":"/report_filters/test_runs"},{"name":"test_plans","type":"array","description":"Integer plan ids \u2014 pairs with report_timeframe=specific_test_plans.","json_path":"/report_filters/test_plans"},{"name":"test_cases","type":"string","description":"Case selection \u2014 pairs with report_timeframe=specific_test_cases. FOLDER-KEYED\n(see SelectionData), never a flat id list: the server resolves the selection\nthrough its folder map, so any other shape yields zero cases and saves an\nUNSCOPED, project-wide report at 200.\n\nSend all three keys. Folder ids are STRING keys; test-case ids are INTEGERS\n(the numeric `id`, not the TC-NNN identifier) \u2014 take bo","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":"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.","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 } }.","json_path":"/report_filters/test_plan_selection"},{"name":"builds","type":"array","json_path":"/report_filters/builds"},{"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\n`Test Run Summary`, `Test Run Detailed Report` and `Test Case Activity` \u2014 on\nthe other six report types the server never reads it and drops it silently.\nPersisted (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`."},{"name":"users","type":"array","json_path":"/mail_to/users"},{"name":"external_mails","type":"array","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\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"},{"name":"time","type":"string","example":"08:00","description":"24-hour HH:MM, UTC.","json_path":"/frequency_details/time"},{"name":"day","type":"string","example":"monday","description":"Required for weekly (lowercase day name) and monthly (integer 1-28).\nOmit for daily.","json_path":"/frequency_details/day"}],"intent":"Update a scheduled report","guidance":["FULL REPLACE, not a delta: omitted fields are nulled and dropping frequency cancels the schedule","Update the configuration of an existing scheduled report for a project"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"]},{"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":"Title of the shared step"},{"name":"folder_id","type":"integer","description":"ID of the shared-field folder to move the shared step into. Null moves it to the root (Unassigned)."},{"name":"shared_step_details","type":"array","required":true,"fields":[{"name":"id","type":"integer"},{"name":"step","type":"string"},{"name":"result","type":"string"},{"name":"order","type":"integer"},{"name":"test_data","type":"string"}]}],"intent":"Update a shared step","guidance":["update a shared step","title and shared_step_details are BOTH required, and the details array REPLACES the existing steps","Updates an existing shared step in a project"],"returns":["id","title"]},{"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"},{"name":"description","type":"string","json_path":"/test_plan/description"},{"name":"start_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/start_date"},{"name":"end_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/end_date"},{"name":"plan_status","type":"string","description":"Plan status. The list filter accepts new / started / completed.","json_path":"/test_plan/plan_status"},{"name":"owner","type":"integer","description":"Owner user id \u2014 resolve via the project users read first.","json_path":"/test_plan/owner"},{"name":"parent_plan_id","type":"integer","description":"Parent plan's INTEGER id. Set it to create (or re-parent into) a SUB-plan \u2014 the\nresult carries an STP-NNN identifier. Null/omitted means a top-level plan.","json_path":"/test_plan/parent_plan_id"},{"name":"tags","type":"array","json_path":"/test_plan/tags"},{"name":"reviewers","type":"array","description":"Reviewer user ids.","json_path":"/test_plan/reviewers"},{"name":"attachments","type":"array","description":"Attachment ids already uploaded via the attachments surface.","json_path":"/test_plan/attachments"},{"name":"custom_fields","type":"object","json_path":"/test_plan/custom_fields"},{"name":"issues","type":"array","description":"Linked tracker issues. Structured \u2014 NOT a flat array of keys.","fields":[{"name":"issue_id","type":"string"},{"name":"issue_type","type":"string"},{"name":"metadata","type":"object"}],"json_path":"/test_plan/issues"},{"name":"test_runs","type":"string","description":"The plan's linked test runs. Accepts EITHER an array of test-run integer ids, OR a\nbulk-selection object for \"every run matching this filter\". On update this REPLACES\nthe existing link set rather than appending.","json_path":"/test_plan/test_runs"}],"intent":"Update a test plan (or sub-plan)","guidance":["Update a plan by its INTEGER id","Takes the same body as create","so it also works on a sub-plan by its own id","clearing it promotes a sub-plan to a top-level plan","do that only when the user actually asked to move it in the hierarchy","Send only the fields you intend to change"]},{"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 AI-generated or AI-processed attachments","guidance":["upload a file as AI CONTEXT (restricted MIME types, smaller size cap) to seed test-case content","Files are stored in S3 with pre-signed URLs","File Storage: Files uploaded to S3 with pre-signed URLs (expires in ~26 minutes)"],"returns":["id","name","content_type","download_url","size","url"]},{"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 generic attachments to a project from rich text editor or other sources","guidance":["UPLOAD file blob(s) (multipart)","Uploads one or more files as generic attachments to a project","Files are stored in S3 and pre-signed URLs are returned for both viewing and downloading"],"returns":["id","name","content_type","download_url","size","url"]},{"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":"Upload one or more attachments to a test case","guidance":["Upload one or more attachments to a specific test case within a folder in a project"],"returns":["id","byte_size","content_type","filename"]},{"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 whose per-test-case / per-run / per-day breakdown is requested."}],"intent":"Fetch the User Workload drill-down for a single user","guidance":["User-Workload per-user execution breakdown","Only valid on a report whose type is User Workload Report","every other type returns 400 \"Drill-down is only available for User Workload Reports\""],"shape":"discovered","paginated":true}],"entities":{"activity":{"entity":"activity","title":"Activity feed (audit trail)","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_v1"]},"attachment":{"entity":"attachment","title":"Attachments","aliases":["attachment","file","attachments","blob"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"attached to"},{"entity":"result","via":"attached to"}],"capabilities":["delete_test_case_attachment","get_test_case_attachments_v1","upload_a_i_attachments","upload_generic_attachments","upload_test_case_attachments_v1"]},"configuration":{"entity":"configuration","title":"Configurations","aliases":["config","configuration","environment","browser","os","device"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_run","via":"case status tracked against"}],"capabilities":["create_configuration_v1","get_configurations_v1"]},"custom_field":{"entity":"custom_field","title":"Custom fields & system fields","aliases":["custom field","custom fields","field","system field"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"extends"},{"entity":"result","via":"extends"}],"capabilities":["create_custom_field_dataset_option_v2","create_custom_field_dataset_v2","create_custom_field_definition_v2","delete_custom_field_dataset_option_v2","delete_custom_field_dataset_v2","delete_custom_field_definition_v2","get_custom_field_dataset_v2","get_custom_field_definition_v2","get_custom_field_values_v1","get_project_id_custom_fields_v2_v1","get_test_results_custom_fields_v1","list_custom_field_dataset_options_v2","list_workspace_fields_v2","update_custom_field_dataset_option_v2","update_custom_field_dataset_project_mapping_v2","update_custom_field_definition_v2"]},"dataset":{"entity":"dataset","title":"Dataset","aliases":["datasets","data-driven-data","dds"],"id_convention":"uuid","parents":["project"],"relations":[{"entity":"test_case","via":"drives"},{"entity":"variable","via":"has columns"}],"capabilities":["create_dataset","delete_dataset","get_dataset","get_dataset_linked_test_cases","get_dataset_variables","import_dataset_c_s_v","list_datasets","update_dataset"]},"duplicate":{"entity":"duplicate","title":"Duplicate recommendation (AI dedupe)","aliases":["duplicate","duplicates","dedupe","deduplication","duplicate recommendation","ai duplicates","redundant test cases"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"links the pair it believes are duplicates"}],"capabilities":["discard_duplicate_v1","get_dedupe_status_v1","get_duplicate_source_test_case_v1","get_duplicate_v1","list_duplicates_v1","resolve_duplicate_v1","search_duplicates_v1"]},"exploratory_session":{"entity":"exploratory_session","title":"Exploratory session","aliases":["session","exploratory","et-session"],"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_v1","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"]},"filter":{"entity":"filter","title":"Saved filter view","aliases":["filter","filters","saved view","saved 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"]},"folder":{"entity":"folder","title":"Folder","aliases":["dir","directory"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"folder","via":"nests under"},{"entity":"test_case","via":"organises"}],"capabilities":["copy_folder","create_bulk_test_cases_v1","create_root_folder_v1","create_sub_folder_v1","delete_folder_and_contents","get_folder_remove_summary","get_folder_tree_v1","get_root_folders_v1","list_folder_contents","move_folder_v1","rename_folder_v1","reorder_folders","undo_remove_folder"]},"project":{"entity":"project","title":"Project","aliases":["pr","workspace"],"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_v1","export_dashboard_analytics","get_active_test_runs_info_v1","get_automation_stats_v1","get_closed_test_runs_info_v1","get_closed_test_runs_split_v1","get_entity_filter_details_v1","get_issues_count_info_v1","get_project_by_integer_id_v1","get_project_settings","get_project_users_v2_v1","get_projects_basic_v1","get_projects_minify_v1","get_test_case_count_trend_v1","get_test_case_type_split_v1","list_projects_v1","search_project_entities","update_project_settings"]},"report":{"entity":"report","title":"Report","aliases":["reports","scheduled-report","schedule","analytics-report"],"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","delete_report_schedule","download_report","get_exploratory_session_summary_v1","get_report_attachment_url","get_report_detail","get_report_section_v1","get_report_summary_by_type","get_selected_report_testcases","list_schedules","send_report_email_now_v1","update_report","user_workload_drilldown"]},"result":{"entity":"result","title":"Result","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_v1","create_step_result_v1","create_test_result_for_test_case","delete_test_result_v1","get_test_results_for_test_case","update_latest_test_result_for_test_case"]},"shared_step":{"entity":"shared_step","title":"Shared step","aliases":["shared step","shared steps","reusable step","step template"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"reused by"}],"capabilities":["create_shared_step_v1","delete_shared_step_v1","get_shared_steps_by_i_d_v1","get_shared_steps_v1","update_shared_step_v1"]},"tag":{"entity":"tag","title":"Tag","aliases":["tags","label","labels"],"id_convention":"name","parents":["project"],"relations":[{"entity":"test_case","via":"labels"},{"entity":"test_run","via":"labels"},{"entity":"test_plan","via":"labels"}],"capabilities":["bulk_edit_test_cases_v2","get_test_case_tags","list_test_case_tags_v1","merge_tags_v1","search_group_tags","search_test_case_tags"]},"test_case":{"entity":"test_case","title":"Test case","aliases":["tc","case","test case"],"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":["bulk_archive_test_cases_by_project","bulk_copy_test_cases","bulk_delete_test_cases","bulk_edit_test_cases","bulk_edit_test_cases_in_test_run","bulk_move_test_cases","bulk_retrieve_test_cases","create_test_case_v1","create_test_cases","edit_test_case_partial_v1","edit_test_case_v1","get_archived_test_cases","get_system_field_values_v1","get_test_case_by_integer_id_v1","get_test_case_detail","get_test_cases_v1","list_folder_test_cases_v1","reorder_test_cases_by_folder_v1","search_project_entities_v2","search_test_cases_by_folder_v1"]},"test_plan":{"entity":"test_plan","title":"Test plan (and sub-plan)","aliases":["tp","plan","milestone","sub test plan","subplan","stp"],"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_v1","bulk_retrieve_test_plans_v1","clone_test_plan_v1","count_test_plan_test_runs_v1","create_test_plan_v1","delete_test_plan_v1","get_archived_test_plan_count_v1","get_test_plan_by_integer_id_v1","get_test_plan_execution_trend_v1","get_test_plan_linked_sessions_chart_data_v1","get_test_plan_test_runs_v1","list_archived_test_plans_v1","list_test_plans_v1","update_test_plan_v1"]},"test_run":{"entity":"test_run","title":"Test run","aliases":["tr","run","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":["assign_test_run_owner","bulk_assign_test_cases_to_test_run","clone_test_run","close_test_run_v1","count_run_selection_v1","create_test_run_v1","delete_v1_test_run","edit_test_run","get_closed_test_runs","get_test_cases_for_v1_test_run","get_test_run_by_integer_id_v1","get_test_run_cases_v1","get_test_runs_form_fields_v1","get_test_runs_selection","get_test_runs_v1"]},"user":{"entity":"user","title":"User","aliases":["users","member","assignee","owner"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"project","via":"member of"},{"entity":"test_run","via":"assigned to"},{"entity":"test_case","via":"owns"}],"capabilities":["get_group_users_v2_v1","search_project_users"]},"version":{"entity":"version","title":"Test case version","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":["get_test_case_histories_v1","get_test_case_history_diff_v1","get_test_case_history_v1","restore_history_v1"]}}}}} \ No newline at end of file diff --git a/tests/tools/capabilityRegistry.test.ts b/tests/tools/capabilityRegistry.test.ts new file mode 100644 index 0000000..c4640c8 --- /dev/null +++ b/tests/tools/capabilityRegistry.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, it } from "vitest"; + +import { bind, coerce } from "../../src/tools/capability-registry/bind.js"; +import { + discoveredFields, isEnvelopeField, itemsOf, projectRow, totalOf, +} from "../../src/tools/capability-registry/envelope.js"; +import { authHeaders } from "../../src/tools/capability-registry/egress.js"; +import { + CapabilityRegistry, InvocationError, IndexError, +} from "../../src/tools/capability-registry/index-loader.js"; +import { invoke } from "../../src/tools/capability-registry/resolve.js"; +import { isCollection, searchCapabilities, terms } from "../../src/tools/capability-registry/search.js"; +import { Capability, RegistryIndex } from "../../src/tools/capability-registry/types.js"; + +const LIST_CASES: Capability = { + method: "GET", path: "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases", + mode: "read", entity: "test_case", paginated: true, max_items: 300, + intent: "List the test cases in one folder", + path_params: [ + { name: "project_id", type: "integer", required: true }, + { name: "folder_id", type: "integer", required: true }, + ], + query: [{ name: "p", type: "integer" }, { name: "count", type: "integer" }], + returns: ["id", "identifier", "title"], +}; + +const CREATE_FOLDER: Capability = { + method: "POST", path: "/api/v1/projects/{project_id}/folders", + mode: "write", entity: "folder", + path_params: [{ name: "project_id", type: "integer", required: true }], + // the nesting the product really wants, which a reader of the flat spec would miss + body: [ + { name: "name", type: "string", required: true, json_path: "/folder/name" }, + { name: "notes", type: "string", json_path: "/folder/notes" }, + ], + returns: ["id", "name"], +}; + +const DELETE_PLAN: Capability = { + 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 }, + ], +}; + +const BULK_MOVE: Capability = { + 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 }], + // the collision that makes a flat argument map ambiguous + body: [{ name: "folder_id", type: "integer" }], +}; + +const INDEX: RegistryIndex = { + schema_version: 1, build_id: "abc123-173caps", + products: { + tm: { + summary: "Test Management", + capabilities: [LIST_CASES, CREATE_FOLDER, DELETE_PLAN, BULK_MOVE], + entities: { test_case: { aliases: ["tc", "case"] }, folder: {}, test_plan: {} }, + }, + }, +}; + +describe("index loader", () => { + it("refuses an index whose schema it does not understand", () => { + // Guessing at a shape the generator announced is exactly where silently wrong tool + // output comes from. + expect(() => new CapabilityRegistry({ ...INDEX, schema_version: 99 })) + .toThrow(IndexError); + }); + + it("finds a capability by endpoint, and says so when it cannot", () => { + const registry = new CapabilityRegistry(INDEX); + expect(registry.byEndpointLookup("get", LIST_CASES.path).capability).toBe(LIST_CASES); + expect(() => registry.byEndpointLookup("GET", "/api/v1/nope")) + .toThrow(/unknown_endpoint/); + }); +}); + +describe("binding", () => { + it("substitutes path params and keeps query separate", () => { + const bound = bind(LIST_CASES, { path_params: { project_id: 2, folder_id: 7 } }); + expect(bound.path).toBe("/api/v1/projects/2/folder/7/test-cases"); + expect(bound.body).toBeUndefined(); + }); + + it("builds the nested body the product expects, not the flat one", () => { + const bound = bind(CREATE_FOLDER, { + path_params: { project_id: 2 }, body: { name: "New", notes: "d" }, + }); + expect(bound.body).toEqual({ folder: { name: "New", notes: "d" } }); + }); + + it("keeps a name declared in two places unambiguous", () => { + // `folder_id` is a body field here while `project_id` is a path one; grouping is what + // makes that expressible at all. + const bound = bind(BULK_MOVE, { path_params: { project_id: 2 }, body: { folder_id: 9 } }); + expect(bound.path).toBe("/api/v1/projects/2/test-cases/bulk-move"); + expect(bound.body).toEqual({ folder_id: 9 }); + }); + + it("refuses an unknown argument instead of dropping it", () => { + // Silently ignoring a misspelled filter returns a larger result set that looks correct. + expect(() => bind(LIST_CASES, { path_params: { project_id: 1, folder_id: 1 }, query: { pp: 1 } })) + .toThrow(/unknown query: pp/); + }); + + it("enforces required body fields, not just path ones", () => { + expect(() => bind(CREATE_FOLDER, { path_params: { project_id: 2 }, body: {} })) + .toThrow(/missing required parameter\(s\): name/); + }); + + it("stops a traversal attempt at the declared type", () => { + expect(() => bind(LIST_CASES, { path_params: { project_id: "../../admin-v2", folder_id: 1 } })) + .toThrow(/must be a number/); + }); + + it("encodes a string path value so it cannot rewrite the route", () => { + const capability: Capability = { + ...LIST_CASES, path: "/api/v1/x/{slug}", + path_params: [{ name: "slug", type: "string", required: true }], query: [], + }; + expect(bind(capability, { path_params: { slug: "a/b" } }).path).toBe("/api/v1/x/a%2Fb"); + }); + + it("checks enums", () => { + expect(() => coerce("nope", { name: "s", type: "string", values: ["low", "high"] })) + .toThrow(/must be one of: low, high/); + }); +}); + +describe("finding rows in a response", () => { + it("finds rows by shape, whatever the envelope calls them", () => { + // tm uses 30 distinct row-key names; a hardcoded list missed 24 of them. + expect(itemsOf({ success: true, path_folders: [{ id: 1 }] })).toEqual([{ id: 1 }]); + }); + + it("treats a single wrapped record as one row", () => { + // Without this every stats/summary/detail read came back ok:true, count:0, items:[]. + expect(itemsOf({ success: true, project: { id: 4 } })).toEqual([{ id: 4 }]); + expect(itemsOf({ id: 9, name: "flat" })).toEqual([{ id: 9, name: "flat" }]); + }); + + it("keeps an empty array distinguishable from a shape with no rows", () => { + expect(itemsOf({ success: true, test_cases: [] })).toEqual([]); + }); + + it("reads the total out of the envelope", () => { + expect(totalOf({ info: { count: 880 } })).toBe(880); + }); + + it("knows an envelope field from a row field", () => { + for (const name of ["total_pages", "has_more", "is_empty", "count", "success"]) { + expect(isEnvelopeField(name)).toBe(true); + } + expect(isEnvelopeField("identifier")).toBe(false); + }); +}); + +describe("projection", () => { + it("keeps only declared fields", () => { + expect(projectRow({ id: 1, secret_note: "x" }, ["id"], false)).toEqual({ id: 1 }); + }); + + it("emits nothing when nothing is declared and discovery is off", () => { + expect(projectRow({ id: 1 }, [], false)).toEqual({}); + }); + + it("discovers scalars but never expands an object or a sensitive name", () => { + // `assignee` expands to email/full_name/browserstack_user_id; field_values carry + // signed URLs. Both are objects, so neither can travel this path. + expect(discoveredFields({ + id: 7, identifier: "TP-3", assignee: { email: "a@b.c" }, tags: ["x"], + api_token: "t", user_email: "a@b.c", user_id: 4, + })).toEqual({ id: 7, identifier: "TP-3" }); + }); +}); + +describe("search", () => { + it("does not treat verbs as stopwords", () => { + expect(terms("list the test cases")).toEqual(["list", "test", "cases"]); + }); + + it("prefers a collection for a plural query", () => { + expect(isCollection(LIST_CASES)).toBe(true); + expect(isCollection(DELETE_PLAN)).toBe(false); + }); + + it("matches through the aliases the harness authored", () => { + const hits = searchCapabilities(INDEX.products, "tc"); + expect(hits.capabilities.map((c) => c.entity)).toContain("test_case"); + }); + + it("ranks a write query onto the write endpoint", () => { + const hits = searchCapabilities(INDEX.products, "create a folder"); + expect(hits.capabilities[0].path).toBe(CREATE_FOLDER.path); + }); + + it("lets a penalty reorder without excluding", () => { + // A cardinality penalty used to take a valid score to zero, and 40 legitimate matches + // vanished — the caller saw "no such capability". + const hits = searchCapabilities(INDEX.products, "list test cases"); + expect(hits.total_matched).toBeGreaterThan(1); + }); +}); + +describe("auth", () => { + it("forwards the caller's credentials as Api-Token", () => { + // HTTP Basic is not usable on /api/v1; Api-Token is what the whole surface accepts. + const headers = authHeaders({ username: "ing_Xx", accessKey: "SECRET" }); + expect(headers["Api-Token"]).toBe("ing_Xx:SECRET"); + expect(headers["request-source"]).toBe("ai-chatbot"); + }); + + it("refuses rather than sending unauthenticated", () => { + expect(() => authHeaders({ username: "u", accessKey: "" })).toThrow(InvocationError); + }); +}); + +describe("invoke", () => { + const credentials = { username: "u", accessKey: "k" }; + + it("pages to completion at the declared ceiling and projects the rows", async () => { + const seen: { query: Record; headers: Record }[] = []; + const transport = async ( + _m: string, _u: string, headers: Record, query: Record, + ) => { + seen.push({ query, headers }); + const page = Number(query.p || 1); + const start = (page - 1) * 300; + const rows = Array.from({ length: Math.max(0, Math.min(300, 880 - start)) }, (_v, i) => ({ + id: start + i, identifier: `TC-${start + i}`, title: "t", leaked: "no", + })); + return { status: 200, body: { test_cases: rows, info: { count: 880 } } }; + }; + const result = await invoke( + LIST_CASES, { path_params: { project_id: 1, folder_id: 2 } }, + "https://tm.example", credentials, transport, + ); + expect(result.ok).toBe(true); + expect(result.count).toBe(880); + expect(result.requests_made).toBe(3); // not 30 at the product's default + expect(seen[0].query.count).toBe(300); + expect(seen[0].headers["Api-Token"]).toBe("u:k"); + expect(Object.keys(result.items[0])).toEqual(["id", "identifier", "title"]); + }); + + it("reports drift rather than an empty answer", async () => { + const transport = async () => ({ status: 200, body: { rows: [{ unrelated: 1 }] } }); + const result = await invoke( + LIST_CASES, { path_params: { project_id: 1, folder_id: 2 } }, + "https://tm.example", credentials, transport, + ); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/capability_returns_drift/); + }); + + it("surfaces a non-2xx as a failed call, not as empty rows", async () => { + const transport = async () => ({ status: 401, body: { error: "Unauthorized" } }); + const result = await invoke( + LIST_CASES, { path_params: { project_id: 1, folder_id: 2 } }, + "https://tm.example", credentials, transport, + ); + expect(result.ok).toBe(false); + expect(result.error).toMatch(/401/); + }); +}); diff --git a/tests/tools/capabilityRegistryArtifact.test.ts b/tests/tools/capabilityRegistryArtifact.test.ts new file mode 100644 index 0000000..8113a74 --- /dev/null +++ b/tests/tools/capabilityRegistryArtifact.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { fileURLToPath } from "node:url"; + +import { CapabilityRegistry } from "../../src/tools/capability-registry/index-loader.js"; +import { searchCapabilities } from "../../src/tools/capability-registry/search.js"; +import { bind } from "../../src/tools/capability-registry/bind.js"; + +const FIXTURE = fileURLToPath(new URL("../fixtures/registry-index.json", import.meta.url)); +const registry = CapabilityRegistry.fromFile(FIXTURE); + +describe("the real artifact", () => { + it("loads every capability the Python build emitted", () => { + expect(registry.buildId).toMatch(/caps/); + expect(registry.index.products.tm.capabilities).toHaveLength(173); + expect(Object.keys(registry.index.products.tm.entities)).toHaveLength(19); + }); + + it("carries no internal machinery — the reason we ship an index, not the specs", () => { + const blob = JSON.stringify(registry.index); + for (const forbidden of ["x-atlas-permission", '"target"', '"pointer"', "key_facts", + '"operations"', "strip_prefix", "page_param", "count_param"]) { + expect(blob).not.toContain(forbidden); + } + }); + + it("bakes in no hostname, so one artifact ships to every environment", () => { + const blob = JSON.stringify(registry.index); + for (const host of ["bsstag.com", "browserstack.com", "https://"]) { + expect(blob).not.toContain(host); + } + }); + + it("answers a real query with a usable endpoint", () => { + const hits = searchCapabilities(registry.index.products, "list the test cases in a folder"); + expect(hits.capabilities.length).toBeGreaterThan(0); + const top = hits.capabilities[0]; + expect(top.method).toBeTruthy(); + expect(top.path.startsWith("/api/")).toBe(true); + expect(top.mode).toBe("read"); + }); + + it("refuses every destructive endpoint before binding", () => { + const destructive = registry.index.products.tm.capabilities + .filter((capability) => capability.mode === "destructive"); + // 16 in tm: 6 real DELETEs plus 10 POSTs whose path ends in delete/rm. + expect(destructive.length).toBe(16); + }); + + it("binds a real endpoint end to end from what search returned", () => { + const { capability } = registry.byEndpointLookup( + "GET", "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases", + ); + const bound = bind(capability, { path_params: { project_id: 379320413, folder_id: 750414 } }); + expect(bound.path).toBe("/api/v1/projects/379320413/folder/750414/test-cases"); + }); + + it("marks the endpoints whose response the product never declared", () => { + const discovered = registry.index.products.tm.capabilities + .filter((capability) => capability.shape === "discovered"); + expect(discovered.length).toBe(32); + }); +}); From a0f5a206103650a8921237cd4f60cdd04e9ea16a Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Wed, 19 Aug 2026 16:20:25 +0530 Subject: [PATCH 02/31] Wire the capability registry into the server factory MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registered alongside the existing tool adders, conforming to their contract — (server, config) -> Record, with trackMCP instrumentation in the house style. Credentials come from BrowserStackConfig and are read PER CALL, not captured, because the remote server rebuilds config per session and a captured credential would outlive its session. Fails soft on purpose: a missing or unreadable index registers nothing and logs why, rather than throwing. A packaging problem must not take every other product's tools down with it, and a test asserts the other adders still register when the artifact is absent. CAPABILITY_REGISTRY_DISABLED=true is the kill switch; CAPABILITY_REGISTRY_BASE_URL_ overrides the host per environment, since no hostname is baked into the artifact. Fixes a real regression the end-to-end test caught. The port had been reading the page-size ceiling off `max_items`, but that is a total-items cap on the Python side; the ceiling lives on Operation.max_count, which `project()` deliberately withholds because publishing page controls invites a caller to drive paging itself — the 17 Aug failure. Nothing carried it into the artifact, so the resolver was about to page at the product's default: exactly the incident, reintroduced by the port. Paging controls now travel in a sibling `paging` map keyed "METHOD /path" (44 endpoints, 14 with a declared ceiling). The resolver reads it; the search tool never echoes it, so the published surface is unchanged and a test asserts no page control appears in a search hit. End-to-end coverage through the factory, with fetch stubbed: the five tools register beside the hand-written ones; a real search returns a usable endpoint; an invoke forwards `Api-Token: :` plus the attribution header, sends count=300 rather than the product's default, and projects rows to the declared returns so an undeclared field never reaches the caller; a destructive endpoint is refused with no egress at all; and a write is refused until confirmed, with a typo'd parameter surfacing as a parameter error rather than sending someone to ask a human about a call that was never going to run. capability-index.json ships at the package root and is listed in package.json `files`, because tsc compiles TS and does not copy JSON into dist. Full suite: 319 tests across 32 files, lint and tsc clean. --- capability-index.json | 1 + package.json | 3 +- src/server-factory.ts | 5 + src/tools/capability-registry/config.ts | 44 ++++++ src/tools/capability-registry/index-loader.ts | 7 + src/tools/capability-registry/register.ts | 106 ++++++++++++-- src/tools/capability-registry/resolve.ts | 20 +-- src/tools/capability-registry/types.ts | 17 +++ tests/fixtures/registry-index.json | 2 +- tests/tools/capabilityRegistry.test.ts | 28 +++- tests/tools/capabilityRegistryE2E.test.ts | 138 ++++++++++++++++++ 11 files changed, 338 insertions(+), 33 deletions(-) create mode 100644 capability-index.json create mode 100644 src/tools/capability-registry/config.ts create mode 100644 tests/tools/capabilityRegistryE2E.test.ts diff --git a/capability-index.json b/capability-index.json new file mode 100644 index 0000000..6a8157d --- /dev/null +++ b/capability-index.json @@ -0,0 +1 @@ +{"schema_version":1,"build_id":"3c872d0b7b-173caps-8bf0e50","harness_commit":"8bf0e508","products":{"tm":{"summary":"BrowserStack Test Management API (v1 \u2014 the SSO/OAuth app API, cookie-authenticated).","capabilities":[{"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"}],"body":[{"name":"owner","type":"integer","description":"The ID of the user to assign as the owner of the test run."}],"intent":"Assign a owner to the test run","guidance":["Assign a user as the owner of a specific test run within a project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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, verified live: 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":"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":["Archive multiple test cases within a project","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"paginated":true},{"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,"description":"Plan INTEGER ids."}],"intent":"Archive test plans in bulk (reversible \u2014 prefer this over delete)","guidance":["REVERSIBLY remove plans from view","Async above the bulk threshold","Soft-archive one or more plans","reach for it whenever the user wants plans \"removed\" or \"cleaned up\" without saying they must be destroyed"],"returns":["async","unique_id"]},{"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"}],"query":[{"name":"include_subfolders_tc","type":"boolean"}],"body":[{"name":"assignee_id","type":"integer","example":2,"description":"ID of the assignee to whom the test cases will be assigned"},{"name":"mapping_ids","type":"array","example":[999,991,1024,1019],"description":"List of test case IDs to be linked"},{"name":"select_all","type":"boolean","example":false,"description":"Flag to select all test cases"},{"name":"de_selected_ids","type":"array","description":"List of test case IDs to be de-selected"},{"name":"folder_id","type":"integer","description":"ID of the folder to which the test cases belong"}],"intent":"Bulk assign test cases to a test run","guidance":["assign specific cases within a run to a user (async above 300)","Assign multiple test cases to a specific test run within a project, allowing for bulk operations on test case assignments"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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` \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, verified live: an unrecognised key here is SILENTLY DROPPED, so a typo widens the ta"},{"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,"json_path":"/test_case/ids"},{"name":"de_selected_ids","type":"array","required":true,"json_path":"/test_case/de_selected_ids"},{"name":"select_all","type":"boolean","required":true,"json_path":"/test_case/select_all"},{"name":"folder_id","type":"string","description":"SOURCE folder that scopes the selection, at top level. OPTIONAL when you pass explicit `ids` (verified live: the copy succeeds without it), but send it whenever `select_all` is true \u2014 it is what bounds the set, and `select_all` with `folder_id` and no `q` copies exactly that folder's cases. Integer and string are both accepted; the declared string matches what the UI sends here, which unlike bulk-"},{"name":"destination_folder_id","type":"integer","required":true,"description":"Target folder for the copies, and the one genuinely required destination field \u2014 omitting it is a bare `400`, verified live. NOTE it is TOP-LEVEL for copy, unlike bulk-move where the destination sits inside `test_case`."},{"name":"destination_project_id","type":"string","description":"Target project id. Needed only for a copy to ANOTHER project. For a copy WITHIN one project it is OPTIONAL \u2014 verified live, omitting it copies correctly into `destination_folder_id`, and integer and string are both accepted. The product does both: its Copy/Move modal sends the source project id, its clone / drag-and-drop path omits it. Send the source id to be explicit or omit it; never invent a v"}],"intent":"Bulk copy test cases","guidance":["copy a selection to a folder (async above 30)","Bulk copy test cases from one folder to another within a project","This operation allows you to copy multiple test cases at once, which can be useful for organizing or duplicating test cases across different folders","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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` \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, verified live: 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":"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 delete.","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":"Delete every case in scope, minus `de_selected_ids`.","json_path":"/test_case/select_all"}],"intent":"Bulk Delete Test Cases from Search","guidance":["All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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."}],"intent":"Delete test results in bulk \u2014 PARTIAL SUCCESS is normal, always read `skipped`","guidance":["PARTIAL SUCCESS is normal, always read the skipped array back","Authorisation is enforced per id (a caller must be the result's author or a project admin)","treating a 200 as \"all deleted\" will silently misreport","result_ids must be a non-empty array (400 otherwise) of at most 300 ids (422 beyond that)","batch larger sets yourself"],"returns":["id","reason"]},{"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` \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, verified live: 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":"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 edit.","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":"automation_status","type":"string","json_path":"/test_case/automation_status"},{"name":"case_type","type":"integer","json_path":"/test_case/case_type"},{"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"},{"name":"owner","type":"integer","description":"TM user id.","json_path":"/test_case/owner"},{"name":"preconditions","type":"string","json_path":"/test_case/preconditions"},{"name":"priority","type":"integer","json_path":"/test_case/priority"},{"name":"status","type":"integer","json_path":"/test_case/status"},{"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"}],"intent":"Bulk Update Test Cases","guidance":["bare values, REPLACE-only (async above 100)","Update multiple test cases within a project","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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"}],"query":[{"name":"include_subfolders_tc","type":"boolean"},{"name":"status_id","type":"integer"}],"body":[{"name":"attachments","type":"array","example":[]},{"name":"de_selected_ids","type":"array","example":[]},{"name":"description","type":"string","example":"

something

","description":"HTML formatted comment or note"},{"name":"folder_id","type":"integer","example":21},{"name":"issues","type":"array","example":[]},{"name":"mapping_ids","type":"array","example":[999,991,1024,1019]},{"name":"select_all","type":"boolean","example":false},{"name":"status_id","type":"integer","example":21},{"name":"test_run_ids","type":"array","example":[1001,1002,1003],"description":"Array of BTCER IDs to apply the bulk edit to (passed when automation = true)"},{"name":"test_case_counts","type":"array","example":[1,3,2],"description":"Array of test case counts corresponding to each BTCER ID in test_run_ids (passed when automation = true)"}],"intent":"Bulk edit test cases result in test run","guidance":["Update multiple test cases in a specific test run within a project, allowing for bulk operations such as changing status, adding attachments, and more"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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, verified live: an unrecognised key here is SILENTLY DROPPED, so a typo widens the ta"},{"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},{"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},{"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`.\n\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},{"name":"value","type":"string"}],"json_path":"/test_case/priority"},{"name":"status","type":"string","fields":[{"name":"operation","type":"string","required":true},{"name":"value","type":"string"}],"json_path":"/test_case/status"},{"name":"case_type","type":"string","fields":[{"name":"operation","type":"string","required":true},{"name":"value","type":"string"}],"json_path":"/test_case/case_type"},{"name":"automation_state","type":"string","fields":[{"name":"operation","type":"string","required":true},{"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},{"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},{"name":"value","type":"string"}],"json_path":"/test_case/owner"},{"name":"preconditions","type":"string","description":"Text value.","fields":[{"name":"operation","type":"string","required":true},{"name":"value","type":"string"}],"json_path":"/test_case/preconditions"},{"name":"review_status","type":"string","fields":[{"name":"operation","type":"string","required":true},{"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},{"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":["PREFERRED bulk write","per-field { value, operation }","Correct for one case too (pass a single id)","Update many test cases in ONE call","Every field is an object of the form {\"value\": \u2026, \"operation\": \u2026}","so this is the only write path that can ADD or REMOVE from a collection without replacing it"],"returns":["async","unique_id"],"paginated":true},{"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` \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, verified live: 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":"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 move.","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":"Move every case in scope, minus `de_selected_ids`.","json_path":"/test_case/select_all"},{"name":"destination_folder_id","type":"integer","description":"Target folder id. Falls back to `folder_id` when omitted.","json_path":"/test_case/destination_folder_id"},{"name":"folder_id","type":"integer","description":"Legacy alias for `destination_folder_id`, used only when that is absent.","json_path":"/test_case/folder_id"},{"name":"destination_project_id","type":"integer","description":"Set only for a cross-project move. Shared cases cannot be moved across projects (422).","json_path":"/test_case/destination_project_id"}],"intent":"Bulk Move Test Cases","guidance":["Move multiple test cases from one folder to another within a project","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"paginated":true},{"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":"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, verified live: 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":"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 Retrieve Test Cases","guidance":["Retrieve multiple test cases within a project based on specified criteria","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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,"description":"Plan INTEGER ids."}],"intent":"Un-archive test plans in bulk (undo bulk-archive)","guidance":["Restore previously archived plans"],"returns":["async","unique_id"]},{"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":"Clone an exploratory session (async when it has many logs).","guidance":["clone a session (async when it has many logs)"]},{"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","description":"Name for the clone. Defaults to a server-generated copy name.","json_path":"/test_plan/name"},{"name":"copy_all_sub_plans","type":"boolean","json_path":"/test_plan/copy_all_sub_plans"},{"name":"sub_plan_ids","type":"array","description":"Clone only these sub-plans. Ignored when copy_all_sub_plans is true.","json_path":"/test_plan/sub_plan_ids"}],"intent":"Clone a test plan, optionally with its sub-plans","guidance":["copy_all_sub_plans or sub_plan_ids to bring its sub-plans along","Copy an existing plan","copy_all_sub_plans: true clones every sub-plan","alternatively pass sub_plan_ids to clone a specific subset","Omit both to clone just the plan itself"]},{"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"}],"body":[{"name":"copy_tc_assignee","type":"boolean","description":"Copy test case assignees from the original run"},{"name":"copy_issues","type":"boolean","description":"Copy issues linked to the original run"},{"name":"copy_tags","type":"boolean","description":"Copy tags from the original run"},{"name":"copy_all_cases","type":"boolean","description":"Include all test cases in the cloned run"},{"name":"status","type":"array","example":["passed","failed"],"description":"Status strings to filter test cases to copy"},{"name":"status_id","type":"array","example":[1,2],"description":"Status IDs to filter test cases to copy"},{"name":"name","type":"string","example":"Cloned Test Run - 2025","description":"Name for the new cloned test run"}],"intent":"Clone a test run","guidance":["Create a new test run by cloning an existing one, with options to copy test case assignees, issues, tags, and filter cases by status"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"Close an exploratory session","guidance":["Transitions an active exploratory session to the closed state"]},{"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"}],"intent":"Close a test run","guidance":["Close a specific test run within a project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"ID of folder to copy"},{"name":"destination_folder_id","type":"integer","required":true,"example":3,"description":"ID of destination parent folder"},{"name":"destination_project_id","type":"integer","required":true,"example":10000,"description":"Destination project ID (can be same or different)"},{"name":"async","type":"boolean","example":true,"description":"Whether to process asynchronously via background job"},{"name":"copy_test_cases","type":"boolean","example":true,"description":"Whether to copy test cases within folder"}],"intent":"Copy a folder to another location","guidance":["Copies a folder (and optionally its contents) to a destination folder within the same or different project","Supports both synchronous and asynchronous operations via the async flag","Async Processing: When async: true, the operation is queued as a Sidekiq background job and returns immediately with a unique tracking ID"],"returns":["async","folder_id","unique_id"]},{"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","example":false,"description":"Select every case in the project.","json_path":"/selection/select_all"},{"name":"unique_test_case_count","type":"integer","example":2,"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.","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`."},{"name":"configurations","type":"array","required":true,"example":[],"description":"Configuration ids. Required \u2014 pass an empty array if there are none."},{"name":"trtc_configuration_mappings","type":"array","description":"Per-configuration case mappings. An ARRAY of objects \u2014 the server rejects an object here.","fields":[{"name":"configuration_id","type":"integer"},{"name":"select_all","type":"boolean"},{"name":"linked_test_cases","type":"array"},{"name":"unlinked_test_cases","type":"array"}]}],"intent":"Count the test cases a selection resolves to (incl. configurations/datasets).","guidance":["count the cases a selection resolves to (incl"],"shape":"discovered"},{"method":"GET","path":"/api/v1/projects/{project_id}/test-plans/{test_plan_id}/test-runs/count","mode":"read","entity":"test_plan","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"test_plan_id","type":"integer","required":true}],"intent":"Count the test runs linked to a test plan","guidance":["how many runs a plan groups","cheaper and safer than paging the run list","Returns just the count of runs linked to the plan","Prefer this over paging the run list when the user only asked \"how many\"","list reads truncate around 30 KB and will make you under-count"],"shape":"discovered"},{"method":"POST","path":"/api/v1/projects/{project_id}/folder/{folder_id}/bulk-test-cases","mode":"write","entity":"folder","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"folder_id","type":"integer","required":true}],"body":[{"name":"test_cases","type":"array","required":true,"fields":[{"name":"name","type":"string","required":true},{"name":"test_case_folder_id","type":"string","required":true},{"name":"attachments","type":"array"},{"name":"automation_state","type":"integer"},{"name":"automation_status","type":"string"},{"name":"case_type","type":"integer"},{"name":"custom_fields","type":"object"},{"name":"description","type":"string"},{"name":"estimate","type":"string"},{"name":"estimated_duration_seconds","type":"integer"},{"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"},{"name":"reviewers","type":"array"},{"name":"send_for_review","type":"boolean"},{"name":"shared_precondition_id","type":"integer"},{"name":"status","type":"integer"},{"name":"tags","type":"array"},{"name":"template","type":"string"},{"name":"template_id","type":"integer"},{"name":"template_step_type","type":"string"},{"name":"test_case_dataset","type":"string"},{"name":"test_case_steps","type":"array"}]},{"name":"unique_id","type":"string","description":"Client-supplied idempotency key. When present and the group has the async bulk-create feature flag enabled, the request is processed asynchronously and acknowledged with 202; completion is delivered over the common WebSocket channel keyed by this id."}],"intent":"Create test cases in bulk for a folder (\u226410) \u2014 UNRELIABLE STATUS, see description.","guidance":["Creates several test cases in one request","more returns 400 \"More than permitted test cases sent\"","A 500 here does NOT mean nothing happened","The action is wrapped in a catch-all rescue that renders 500 {\"success\": false, \"message\": \"Failed to create test cases.\"} for any exception, including ones raised AFTER the cases were already inserted","Same status, same message, opposite outcomes","So on a 500: never retry blind"],"returns":["request_trace_id"]},{"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 new configuration for a project","guidance":["create a configuration ({ name })","Create a new configuration in a specific project"],"returns":["id","name","created_at","group_id","is_suggested","record_status"]},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"body":[{"name":"option_value","type":"string","required":true,"description":"The option label."},{"name":"is_default","type":"boolean","required":true,"description":"REQUIRED \u2014 a missing value is a 400. Must be 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 parent option this hangs under."}],"intent":"Add one option to a dataset \u2014 how you add a dropdown value.","guidance":["ADD one dropdown option","option_value + is_default both required","Adds a single option","Both option_value and is_default are required","a missing is_default is 400 \"Invalid Params\"","For dropdown, at most one option may be the default"]},{"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}],"body":[{"name":"linked_projects","type":"array","description":"Project INTEGER ids this dataset applies to. This is what makes the field visible in a project \u2014 a dataset with no linked projects leaves the field invisible everywhere. NOTE the spelling differs from the project-mappings endpoint, which uses `link_projects` / `unlink_projects`."},{"name":"link_to_future_projects","type":"boolean","description":"Auto-link projects created later."},{"name":"linked_to_all_projects","type":"boolean","description":"Apply to every project in the workspace."},{"name":"options","type":"array","description":"The allowed values, for dropdown-style fields.","fields":[{"name":"option_value","type":"string","required":true},{"name":"is_default","type":"boolean","required":true},{"name":"parent_option_id","type":"integer"}]}],"intent":"Add a dataset (an option set + its project links) to an existing field.","guidance":["add another option set + project scope to an existing field","A dataset is how a field carries options and project scope","A field can hold more than one, letting different projects see different option sets for the same field","the key is linked_projects, and options is accepted at this level (it is a dataset body, not a field body)","for other types a dataset with linked_projects and no options is what scopes the field to projects"]},{"method":"POST","path":"/api/v1/admin-v2/settings/custom-fields","mode":"write","entity":"custom_field","body":[{"name":"field_name","type":"string","required":true},{"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 (e.g. `field_dropdown`) are a different API's and are rejected. Silently immutable after creation: an update that changes it returns 200 and changes nothing."},{"name":"entity_type","type":"string","values":["TestCase","TestResult","TestPlan","ExploratorySession"],"description":"Which entity the field hangs off. One field belongs to exactly one."},{"name":"is_required","type":"boolean","required":true,"description":"REQUIRED, and must be a literal boolean \u2014 omitting it is a 400."},{"name":"datasets","type":"array","required":true,"description":"REQUIRED for every field type, dropdown or not \u2014 omitting it is a 400. Carries the options and the project scope. Send one entry with `linked_projects` set.","fields":[{"name":"linked_projects","type":"array"},{"name":"link_to_future_projects","type":"boolean"},{"name":"linked_to_all_projects","type":"boolean"},{"name":"options","type":"array"}]},{"name":"place_holder_text","type":"string"},{"name":"default_value","type":"string","description":"Only honoured when `field_type` is `boolean`."},{"name":"levels","type":"array","description":"REQUIRED when field_type is `nested_dropdown`, minimum 2 entries, each {name, level_type}."}],"intent":"Create a custom field DEFINITION (workspace-admin action).","guidance":["options AND project scope both go in datasets[]","Creates the field definition","a new attribute available on the chosen entity type","configurable per workspace","so don't predict access","A caller without it gets 403"]},{"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,"description":"Name of the dataset.","json_path":"/dataset/name"},{"name":"variables","type":"array","required":true,"description":"List of dataset variables/columns (max 40).","fields":[{"name":"name","type":"string","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/variables"},{"name":"rows","type":"array","required":true,"description":"List of dataset rows (max 100).","fields":[{"name":"row_number","type":"integer","required":true},{"name":"row_data","type":"array","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/rows"}],"intent":"Create a new dataset.","guidance":["create a dataset with variables + rows","Create a new dataset with variables and rows for a project"],"returns":["name","created_at","rows_count","uuid"]},{"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.","json_path":"/exploratory_session/title"},{"name":"description","type":"string","example":"Explore edge cases in the checkout flow.","description":"Optional description.","json_path":"/exploratory_session/description"},{"name":"charter","type":"string","example":"Focus on payment error handling","description":"Testing charter describing the scope and goals.","json_path":"/exploratory_session/charter"},{"name":"timebox_duration","type":"integer","example":60,"description":"Planned duration in minutes.","json_path":"/exploratory_session/timebox_duration"},{"name":"owner","type":"integer","example":42,"description":"TCM user ID of the session owner / assignee.","json_path":"/exploratory_session/owner"},{"name":"assignee","type":"integer","example":42,"description":"Alias for `owner`. TCM user ID of the assignee.","json_path":"/exploratory_session/assignee"},{"name":"folder_id","type":"integer","example":456,"description":"ID of the folder to place this session in.","json_path":"/exploratory_session/folder_id"},{"name":"tags","type":"array","example":["regression","mobile"],"description":"Tags for the session.","json_path":"/exploratory_session/tags"},{"name":"configurations","type":"array","example":[1,3],"description":"Configuration IDs to associate with this session.","json_path":"/exploratory_session/configurations"},{"name":"attachments","type":"array","example":[101,102],"description":"Blob / media attachment IDs.","json_path":"/exploratory_session/attachments"},{"name":"issues","type":"array","description":"Issues to link to this session.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/exploratory_session/issues"}],"intent":"Create an exploratory session","guidance":["body wrapped in exploratory_session (title required)","Creates a new exploratory session within the specified project"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"]},{"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 button \u2014 spinner appeared but order was not created.

","description":"Rich-text HTML content of the log entry.","json_path":"/session_log/content"},{"name":"status","type":"string","values":["pass","fail","bug","retest","block","note"],"example":"fail","description":"Test result status for this log step.","json_path":"/session_log/status"},{"name":"elapsed_time","type":"integer","example":120,"description":"Time elapsed for this step in seconds.","json_path":"/session_log/elapsed_time"},{"name":"defects","type":"array","description":"Defects to link to this log entry.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/session_log/defects"}],"intent":"Create a session log entry","guidance":["add a log entry (rich-text HTML, sanitised on write)","Adds a new log entry to the specified exploratory session","Rich-text HTML content is sanitised before storage"],"returns":["id","status","content","created_at","elapsed_time","session_id","updated_at"]},{"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,"description":"Name of the filter"},{"name":"entity","type":"string","required":true,"values":["testcase","testplan","testrun","exploratory_session","test_plan_test_case"],"description":"Entity type the filter applies to. The server's validator accepts five values (the\nlast two were missing here); an unlisted value returns 400 \"Invalid JSON data\"."},{"name":"is_project_level","type":"boolean","description":"Whether this filter is available at project level"},{"name":"filters","type":"object","required":true,"description":"Filter criteria object containing the actual filter conditions"}],"intent":"Create a new filter","guidance":["save a filter view ({ name, entity, filters, is_project_level })","Create a new saved filter for test cases, test plans, or test runs in a project"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"]},{"method":"POST","path":"/api/v1/projects","mode":"write","entity":"project","body":[{"name":"name","type":"string","required":true,"json_path":"/project/name"},{"name":"description","type":"string","json_path":"/project/description"}],"intent":"Create a new project.","guidance":["Pinned to human approval","Create a new project with name and description"],"returns":["name","description"]},{"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},{"name":"report_type","type":"string","required":true,"values":["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","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_statu"},{"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 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`"},{"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;\n`custom_report` is a user-configured one. For a Requirement Traceability\nreport, `system_generated` makes the server auto-populate\n`report_filters.requirements` with every requirement in the project."},{"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"},{"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":"Integer run ids \u2014 pairs with report_timeframe=specific_test_runs.","json_path":"/report_filters/test_runs"},{"name":"test_plans","type":"array","description":"Integer plan ids \u2014 pairs with report_timeframe=specific_test_plans.","json_path":"/report_filters/test_plans"},{"name":"test_cases","type":"string","description":"Case selection \u2014 pairs with report_timeframe=specific_test_cases. FOLDER-KEYED\n(see SelectionData), never a flat id list: the server resolves the selection\nthrough its folder map, so any other shape yields zero cases and saves an\nUNSCOPED, project-wide report at 200.\n\nSend all three keys. Folder ids are STRING keys; test-case ids are INTEGERS\n(the numeric `id`, not the TC-NNN identifier) \u2014 take bo","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":"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.","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 } }.","json_path":"/report_filters/test_plan_selection"},{"name":"builds","type":"array","json_path":"/report_filters/builds"},{"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\n`Test Run Summary`, `Test Run Detailed Report` and `Test Case Activity` \u2014 on\nthe other six report types the server never reads it and drops it silently.\nPersisted (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`."},{"name":"users","type":"array","json_path":"/mail_to/users"},{"name":"external_mails","type":"array","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\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"},{"name":"time","type":"string","example":"08:00","description":"24-hour HH:MM, UTC.","json_path":"/frequency_details/time"},{"name":"day","type":"string","example":"monday","description":"Required for weekly (lowercase day name) and monthly (integer 1-28).\nOmit for daily.","json_path":"/frequency_details/day"}],"intent":"Create a new scheduled report for a project","guidance":["Create a report configuration under the given project"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"]},{"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","description":"Name of the root folder"},{"name":"notes","type":"string","example":"This folder contains all regression test cases","description":"Optional notes or description for the folder"}],"intent":"Create a root-level folder for test cases in a project","guidance":["Create a new root-level folder for organizing test cases within a project"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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},{"name":"folder_id","type":"integer","description":"ID of the shared-field folder to place the shared step in. Null or omitted means the root (Unassigned)."},{"name":"shared_step_details","type":"array","required":true,"fields":[{"name":"order","type":"integer","required":true},{"name":"step","type":"string"},{"name":"result","type":"string"},{"name":"test_data","type":"string"}]}],"intent":"Create a new shared step in a project","guidance":["Create a new shared step within a project"],"returns":["id","title","step_count","test_case_count"]},{"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},{"name":"test_run_id","type":"string","required":true},{"name":"test_case_id","type":"integer","required":true},{"name":"step_id","type":"integer","required":true}],"body":[{"name":"status_id","type":"integer","json_path":"/test_run_step_result/status_id"},{"name":"description","type":"string","json_path":"/test_run_step_result/description"}],"intent":"Record a per-step result for a case in a run (v1).","guidance":["record a per-step outcome for a step-templated case ({ status_id, description })"]},{"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,"description":"Name of the new folder.","json_path":"/folder/name"},{"name":"notes","type":"string","description":"Description of the new folder.","json_path":"/folder/notes"}],"intent":"create subfolder inside a specific folder","guidance":["Create a new subfolder within a specific folder in a project"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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 the FIRST test case in a project that has no folders (needs create_at_root).","guidance":["Narrow-purpose route: the first case in a project that has zero folders","Verified live on a folderless project: 200, and the folder appears","The route and the flag always travel together","The product's UI derives both from one boolean","no folders exist AND the user picked no folder","A correct refusal, not something to retry: list the folders and pick one"],"returns":["result","step"]},{"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,"fields":[{"name":"name","type":"string","required":true},{"name":"test_case_folder_id","type":"string","required":true},{"name":"attachments","type":"array"},{"name":"automation_state","type":"integer"},{"name":"automation_status","type":"string"},{"name":"case_type","type":"integer"},{"name":"custom_fields","type":"object"},{"name":"description","type":"string"},{"name":"estimate","type":"string"},{"name":"estimated_duration_seconds","type":"integer"},{"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"},{"name":"reviewers","type":"array"},{"name":"send_for_review","type":"boolean"},{"name":"shared_precondition_id","type":"integer"},{"name":"status","type":"integer"},{"name":"tags","type":"array"},{"name":"template","type":"string"},{"name":"template_id","type":"integer"},{"name":"template_step_type","type":"string"},{"name":"test_case_dataset","type":"string"},{"name":"test_case_steps","type":"array"}]},{"name":"create_at_root","type":"boolean"}],"intent":"Create test cases for a folder in a project.","guidance":["create a case in a folder","body wrapped in test_case, name required","Create one or more test cases within a specific folder in a project","If create_at_root is true, the test case will be created at the root of the project"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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","json_path":"/test_plan/name"},{"name":"description","type":"string","json_path":"/test_plan/description"},{"name":"start_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/start_date"},{"name":"end_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/end_date"},{"name":"plan_status","type":"string","description":"Plan status. The list filter accepts new / started / completed.","json_path":"/test_plan/plan_status"},{"name":"owner","type":"integer","description":"Owner user id \u2014 resolve via the project users read first.","json_path":"/test_plan/owner"},{"name":"parent_plan_id","type":"integer","description":"Parent plan's INTEGER id. Set it to create (or re-parent into) a SUB-plan \u2014 the\nresult carries an STP-NNN identifier. Null/omitted means a top-level plan.","json_path":"/test_plan/parent_plan_id"},{"name":"tags","type":"array","json_path":"/test_plan/tags"},{"name":"reviewers","type":"array","description":"Reviewer user ids.","json_path":"/test_plan/reviewers"},{"name":"attachments","type":"array","description":"Attachment ids already uploaded via the attachments surface.","json_path":"/test_plan/attachments"},{"name":"custom_fields","type":"object","json_path":"/test_plan/custom_fields"},{"name":"issues","type":"array","description":"Linked tracker issues. Structured \u2014 NOT a flat array of keys.","fields":[{"name":"issue_id","type":"string"},{"name":"issue_type","type":"string"},{"name":"metadata","type":"object"}],"json_path":"/test_plan/issues"},{"name":"test_runs","type":"string","description":"The plan's linked test runs. Accepts EITHER an array of test-run integer ids, OR a\nbulk-selection object for \"every run matching this filter\". On update this REPLACES\nthe existing link set rather than appending.","json_path":"/test_plan/test_runs"}],"intent":"Create a test plan \u2014 or a SUB-plan, by setting parent_plan_id","guidance":["body wrapped in test_plan","Create a test plan in the project","Everything goes under the test_plan key","test_runs accepts two shapes","Both are honoured server-side","an unknown option is rejected"]},{"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"},{"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":["log a NEW result for a case in a run","Create a new test result for a specific test case within a test run in a project"],"returns":["id","status","backtrace","configuration_id","created_at","created_by_imported","custom_fields","description","error_description","expanded","failure_type","finished_at"]},{"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],"description":"Explicit case ids to pin into the run. Top level, NOT inside `test_run`. Use this or `selection`."},{"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":["Create a new test run for a specific project, which can be used to execute test cases"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"dataset_id","type":"integer","required":true},{"name":"option_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"Delete one option (POST to /delete) \u2014 DESTRUCTIVE, drops values using it.","guidance":["destroys the values that used it","Removing an option deletes the stored values that used it, across every project linked to the dataset","that preserves the values"]},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"Delete a dataset (POST to /delete) \u2014 DESTRUCTIVE, drops its options and values.","guidance":["drops its options and the values the linked projects stored","Removes the option set and unlinks its projects, destroying the values those projects had stored for this field","Name the affected projects before calling"]},{"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}],"intent":"Delete a field definition (POST to /delete) \u2014 DESTRUCTIVE, cascades to stored values.","guidance":["destroys every stored value in every linked project","Name the projects first","Removes the definition and every value stored for it, in every project it is linked to","There is no bin and no undo","Before calling: read the field, say how many projects it is linked to, and name them","If the user only wants it hidden rather than destroyed, that is a different ask"]},{"method":"DELETE","path":"/api/v1/projects/{project_id}/datasets/{uuid}","mode":"destructive","entity":"dataset","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"uuid","type":"string","required":true}],"intent":"Delete a dataset.","guidance":["say it isn't available rather than calling it, and don't substitute by parsing the file yourself","A 422 here means NOT-ENTITLED, not a bad payload","say so and stop, don't retry with a different body","BINDING shape is DIFFERENT from the dataset shape, and this is the usual failure","the dataset's uuid repeated on every column it owns","The binding object has NO top-level uuid or name (both stripped by strong params)"]},{"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":"Delete an exploratory session","guidance":["Deletes an exploratory session"]},{"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":"Delete a session log entry","guidance":["Permanently deletes a log entry from the specified exploratory session"]},{"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 filter","guidance":["entity = testcase | testplan | testrun | exploratory_session","filter_id is an integer","reuse a saved view's filters as the q for a later search or bulk action"]},{"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":"Delete a folder and its contents","guidance":["Deletes a folder by its ID and optionally returns the parent folder's path hierarchy"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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"}],"intent":"Delete a report (this removes the report itself, not just its schedule)","guidance":["returns the remaining reports","merely stopping a report from recurring","The response is the list of remaining reports","There is no bin and no undo","so confirm the report's name with the user first"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"]},{"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","guidance":["affects every test case embedding it","Deletes a shared step from a project"]},{"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":"Delete an attachment from a test case in a folder in a project","guidance":["read the file from that url"],"returns":["id","byte_size","content_type","filename"]},{"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":"Delete a test plan (or sub-plan) \u2014 no bin, no undo","guidance":["The server performs no plan-type check","so this deletes a sub-plan by its own integer id exactly the same way","Deleting a parent plan is destructive to its children"]},{"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 test result","guidance":["Prefer PATCHing the latest result to correct a mistake","Deleting a result removes an execution record","the case's latest status is recomputed from what remains"]},{"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"}],"intent":"delete test run","guidance":["The discovered ids must be CARRIED INTO the create body","discovery alone puts nothing in the run","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","It is validated before the selection","so a bare 400 'invalid data' means a bad test_run field"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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},{"name":"duplicate_id","type":"integer","required":true}],"body":[{"name":"discard_reason","type":"string","description":"Short reason code / label for why this isn't a duplicate."},{"name":"user_feedback","type":"string","description":"Free-text feedback from the user."}],"intent":"Discard a duplicate suggestion \u2014 dismisses it WITHOUT touching test cases","guidance":["DISCARD a suggestion","dismisses it WITHOUT touching either test case","The safe default when the user says it's not a duplicate or is unsure","Mark a recommendation as discarded","This is the safe way to dismiss a suggestion: it changes only the recommendation's status to discarded and leaves both test cases exactly as they are","discard_reason and user_feedback are optional but feed the dedupe model"]},{"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":"Desired export format. Note this is a STRING here, while the same-named field\non `send_email_now` is an ARRAY."},{"name":"required","type":"object","description":"Optional column selection, honoured only for `Test Run Detailed Report`\n(ignored for every other type). Shape:\n`{ \"\": { \"fields\": [\"id\",\"title\",...] } }`. Omit to get the report's\ndefault columns."}],"intent":"Initiate a download of a report in a specified file format","guidance":["Request generation and download channel for the specified report"],"returns":["channel"]},{"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"},{"name":"custom_fields","type":"object","example":{"123":"option_value","456":["multi","select"]},"json_path":"/test_case/custom_fields"},{"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"},{"name":"preconditions","type":"string","json_path":"/test_case/preconditions"},{"name":"priority","type":"integer","example":483,"json_path":"/test_case/priority"},{"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\".","json_path":"/test_case/reviewers"},{"name":"status","type":"integer","example":496,"json_path":"/test_case/status"},{"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":"string","description":"Dataset binding for a data-driven case. On this path it is applied only when `is_dataset_modified` is true.","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"}],"intent":"Partially edit a test case (v2)","guidance":["PREFERRED partial update","send ONLY changed fields","Partially update the details of a test case within a specific folder in a project","it is the authoritative list","send test_case_steps instead - estimate is accepted and then dropped","estimated_duration_seconds is the one that persists"],"returns":["result","step"]},{"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"},{"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"},{"name":"custom_fields","type":"object","json_path":"/test_case/custom_fields"},{"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"},{"name":"name","type":"string","json_path":"/test_case/name"},{"name":"owner","type":"integer","json_path":"/test_case/owner"},{"name":"preconditions","type":"string","json_path":"/test_case/preconditions"},{"name":"priority","type":"integer","json_path":"/test_case/priority"},{"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"},{"name":"tags","type":"array","json_path":"/test_case/tags"},{"name":"template","type":"string","json_path":"/test_case/template"},{"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":"string","description":"Dataset binding for a data-driven case. On create the mappings are always regenerated from this, so `is_dataset_modified` is ignored here.","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","json_path":"/test_case/test_case_steps"}],"intent":"Edit a test case","guidance":["Update the details of a test case within a specific folder in a project","Omitted fields are left untouched EXCEPT template, template_id and review_status, which are reset to defaults"],"returns":["result","step"]},{"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"}],"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],"description":"Explicit case ids to pin into the run. Top level, NOT inside `test_run`. Use this or `selection`."},{"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":"Edit Test Run","guidance":["Update the details of a specific test run within a project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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","pdf","excel"],"example":"csv","description":"Export file format"},{"name":"start_date","type":"string","example":"2025-01-01","json_path":"/filters/start_date"},{"name":"end_date","type":"string","example":"2025-12-31","json_path":"/filters/end_date"},{"name":"status","type":"array","example":["passed","failed"],"json_path":"/filters/status"}],"intent":"Export dashboard analytics data","guidance":["Initiates an asynchronous export of dashboard analytics data in the specified format","The export is processed via background job and returns an export ID for tracking","Async Processing: Data export runs via Sidekiq ExportDashboardWorker::FileExportWorker","Supported Formats: CSV, PDF, Excel (based on type parameter)"],"returns":["export_id"]},{"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":"Get active test run result statistics","guidance":["Retrieve statistics of active test runs for a specific project"],"shape":"discovered"},{"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":"Get archived test cases.","guidance":["Retrieve a list of archived test cases in a specific project"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"Count archived test plans","guidance":["count archived plans","Returns {success, count}","the number of archived plans in the project"],"shape":"discovered"},{"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 automation stats analytics","guidance":["Retrieve automation statistics for a specific project"],"returns":["automated_coverage","automated_test_cases","empty_data","manual_test_cases","total_test_cases"]},{"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":"Get all the closed test runs for a project","guidance":["Retrieve a list of all closed test runs for a specific project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"Get closed test run analytics","guidance":["Retrieve statistics of closed test runs for a specific project"],"returns":["month"]},{"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":"Get closed test runs split analytics","guidance":["Retrieve split statistics of closed test runs for a specific project"],"returns":["name"]},{"method":"GET","path":"/api/v1/projects/{project_id}/configurations","mode":"read","entity":"configuration","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Get configurations for a project","guidance":["Retrieve a list of configurations for a specific project"],"returns":["id","name","created_at","group_id","is_suggested","record_status"]},{"method":"GET","path":"/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets/{dataset_id}","mode":"read","entity":"custom_field","path_params":[{"name":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"Read one dataset \u2014 its project links and option set.","guidance":["read one dataset's project links and options"],"shape":"discovered"},{"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}],"intent":"Read one custom field definition, with its datasets and project links.","guidance":["read one definition + its datasets and linked projects","do this BEFORE any update, which is a full replace","Options are NOT inline","Datasets come back WITHOUT their options inline","Read this before any update","so you need the current values to avoid clearing them"],"shape":"discovered"},{"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},{"name":"entity_type","type":"string","required":true,"values":["test-case","test-result","test-plan"]},{"name":"field_id","type":"string","required":true}],"query":[{"name":"q","type":"string"}],"intent":"Get the allowed values/options of one custom field.","guidance":["If nothing matches, the field doesn't exist in that workspace","say so rather than guessing a field id","Multi-dropdowns need OPTION IDS, not labels","That legacy family writes a table local to the Rails app that is DEPRECATED and that nothing reads","It is blocked at the gate","testhub owns definitions"],"shape":"discovered","paginated":true},{"method":"GET","path":"/api/v1/projects/{project_id}/datasets/{uuid}","mode":"read","entity":"dataset","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"uuid","type":"string","required":true}],"intent":"Get a specific dataset.","guidance":["read one dataset by UUID","Retrieve a specific dataset by its UUID including all variables and rows"],"returns":["name","created_at","rows_count","uuid"]},{"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},{"name":"uuid","type":"string","required":true}],"intent":"Get test cases linked to a dataset","guidance":["list the test cases bound to this dataset (paged p)","Returns the test cases linked to a dataset, identified by its UUID"],"shape":"discovered","paginated":true},{"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"}],"intent":"Get dataset variables/columns.","guidance":["BINDING shape is DIFFERENT from the dataset shape, and this is the usual failure","the dataset's uuid repeated on every column it owns","The binding object has NO top-level uuid or name (both stripped by strong params)","so do NOT copy the dataset's read shape into it","Never report a dataset as linked from the 200 alone","A dataset is addressed by a UUID (the dataset path param), NOT an integer"],"returns":["name","uuid"],"paginated":true},{"method":"GET","path":"/api/v1/projects/{project_id}/ai-duplicates/status","mode":"read","entity":"duplicate","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Dedupe job status \u2014 use this to tell \"feature off\" from \"no duplicates\"","guidance":["ALWAYS call this when the list is empty","Status of the project's most recent dedupe run"],"returns":["status"]},{"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},{"name":"duplicate_id","type":"integer","required":true}],"intent":"Read the pre-merge snapshot of a MERGED duplicate's source test case","guidance":["after a merge, read the pre-merge snapshot of the case that was folded away","After a merge, the source case no longer exists independently","this returns the snapshot captured at merge time","the only way to show the user what was folded away","Only works for status merged (404 otherwise), and the snapshot may legitimately be absent, which also surfaces as a 404 with error: \"Source test case snapshot not available\"","Treat that as \"not retained\", not as an error to retry"],"shape":"discovered"},{"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},{"name":"duplicate_id","type":"integer","required":true}],"intent":"Read one suggested duplicate, with its test cases resolved","guidance":["read one suggestion with its test cases resolved","do this BEFORE merging so the user can see what would be destroyed","Only status=suggested is readable","Full detail for one recommendation, including the actual test cases it links","so you can show the user what would be merged","Only status suggested is readable here"],"shape":"discovered"},{"method":"GET","path":"/api/v1/projects/{project_id}/{entity}/filter-details","mode":"read","entity":"project","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"entity","type":"string","required":true,"values":["test-cases","trtc","test-runs"],"example":"test-cases"}],"query":[{"name":"q[query]","type":"string","example":"login functionality"},{"name":"q[folder_ids]","type":"string","example":"3306878,3669741,5422801,5422802"},{"name":"q[status]","type":"string","example":"9319,9321"},{"name":"q[priority]","type":"string","example":"550376,9261"},{"name":"q[automation_state]","type":"string","example":"18172471,18172473"},{"name":"q[case_type]","type":"string","example":"9379,9375"},{"name":"q[owner]","type":"string","example":"user123,user456"},{"name":"q[assignee]","type":"string","example":"user789,user101"},{"name":"q[tags]","type":"string","example":"regression,smoke"},{"name":"q[created_at]","type":"string","example":"2024-01-01..2024-12-31"},{"name":"q[updated_at]","type":"string","example":"2024-01-01..2024-12-31"}],"intent":"Get filter details for entity","guidance":["Retrieve filter details for a specific entity type (test-cases, trtc, or test-runs) within a project"],"returns":["id","colour","entity_type","field_name","field_value","internal_name"]},{"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":"Get an exploratory session","guidance":["read one session by INTEGER id","Returns a single exploratory session by its ID"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"]},{"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"]},{"name":"start_date","type":"string"},{"name":"end_date","type":"string"},{"name":"session_ids","type":"string"}],"intent":"Exploratory Session Summary \u2014 live view, no saved report needed","guidance":["exploratory-session summary WITHOUT creating a report","Computes an exploratory-session summary on demand","there is no Schedule row behind it","Use it to answer \"summarise our exploratory sessions\" without creating a report first","Returns session counts, bugs found, top testers (resolved to user objects) and the session list"],"shape":"discovered"},{"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":"Get filter details","guidance":["Retrieve details of a specific saved filter by its ID","It does NOT validate or strip invalid custom-field references","a filter saved with a non-existent custom-field id is returned unchanged","A missing or deleted filter comes back as 400 {\"success\": false, \"message\": \"Filter not found\"}, not 404"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"]},{"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":"Get summary of entities to be deleted when removing a folder","guidance":["PREVIEW what deleting a folder will remove before calling rm","Provides a summary of all entities (test cases, recordings, sub-folders) that would be deleted if the folder is removed"],"returns":["active_test_case_count","archived_test_case_count","sub_folders","test_recordings_count"]},{"method":"GET","path":"/api/v1/projects/{project_id}/repository/mapping","mode":"read","entity":"folder","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Get the project's folder tree (v1).","guidance":["the whole project folder TREE in one call (structure array)"],"shape":"discovered"},{"method":"GET","path":"/api/v1/group/users-v2","mode":"read","entity":"user","query":[{"name":"q","type":"string"}],"intent":"Get group users V2 with pagination","guidance":["Retrieve a paginated list of users in the group with search and filtering capabilities, WITHOUT project scoping","used by the Global Search filter dropdowns","Search by user IDs (comma-separated) or by a name string"],"returns":["id","browserstack_user_id","email","full_name","group_id","onboarded","reports_and_notification_enabled"]},{"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":"Get issues count analytics","guidance":["Retrieve the count of issues for a specific project"],"returns":["label"]},{"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","guidance":["read ONE project by its INTEGER id","so for an integer id this is the right read","Do NOT translate first just to fetch the project","It serves two jobs at once: (1) READ","returns the project's full detail (name, description, permissions, \u2026)","In other words it is NOT translation-only"],"returns":["id","identifier","name","created_at","description","jira_mapped","starred","test_cases_count","test_plans_count","test_runs_count","thProjectId","user_role"]},{"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}],"intent":"Get project-specific custom fields V2 for test cases","guidance":["START HERE for CUSTOM fields","Does NOT expose system-field option ids","Retrieve custom fields, default fields, and system fields for test cases in a specific project (V2 format)"],"returns":["id","default_value","entity_type","field_name","field_type","group_id","is_bulk_editable","is_filterable","is_required","linked_projects_count","place_holder_text","system_name"]},{"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"]}],"intent":"Get project settings","guidance":["Returns an array of enabled setting keys"],"returns":["in_review_count","test_plan_in_review_count"]},{"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"}],"intent":"Get project users V2 with pagination","guidance":["Retrieve paginated list of users in the group with search and filtering capabilities","Search by user IDs (comma-separated) or by name string"],"returns":["id","browserstack_user_id","email","full_name","group_id","onboarded","reports_and_notification_enabled"]},{"method":"GET","path":"/api/v1/projects/basic","mode":"read","entity":"project","query":[{"name":"q[query]","type":"string","example":"nishchay3"},{"name":"q[identifier]","type":"string","example":"PR-60863"}],"intent":"Get paginated projects (lean payload) \u2014 use this to list or count projects","guidance":["LIST or COUNT projects","so you can reach a true total without truncating","Those two are the only recognised keys"],"returns":["id","name","description","import_id","normalisedName","starred","thProjectId"],"paginated":true},{"method":"GET","path":"/api/v1/projects/minify","mode":"read","entity":"project","intent":"Get all projects (minified dropdown) \u2014 has NO name search","guidance":["never use it to resolve a named project or to count them","Minified list of active projects, for dropdown-style selection","It accepts no q in any form","the backend call has no query option","so it can only page through everything","Do NOT use it to resolve a project the user named"],"returns":["id","name","description","import_id","normalisedName","starred","thProjectId"],"paginated":true},{"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},{"name":"attachment_id","type":"integer","required":true}],"intent":"Get download URL for a report attachment","guidance":["Retrieve a secure URL to download a specific report attachment"],"returns":["url"]},{"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"},{"name":"report_data_only","type":"boolean"},{"name":"today","type":"string","example":"2025-05-13"}],"intent":"Retrieve a scheduled report's config and data \u2014 the general report read","guidance":["works for every report type","Full configuration plus computed data for a report of ANY type","request the section and look at the result","which is a real finding","Pass sections to compute the sections you need","several in ONE call, cheaper than one request per section"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"],"paginated":true},{"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"}],"query":[{"name":"widget_type","type":"string"},{"name":"segment","type":"string"}],"intent":"Fetch one report widget/section \u2014 works for EVERY report type.","guidance":["Section names are validated per type","*_drilldown sections return paginated ROWS, not aggregates","Returns a single section's data for any report type","This is the general section read","so when you need SEVERAL sections, prefer that form with a comma-separated sections list and take them in one call rather than one request per section","Most sections return the computed aggregate for a widget"],"shape":"discovered","paginated":true},{"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","requirement_traceability_report"]}],"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":"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`"},{"name":"sections","type":"string","example":"test_run_data,test_run_status"},{"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"]},{"name":"segment","type":"string"}],"intent":"Get summary statistics for a scheduled report \u2014 run and traceability types ONLY","guidance":["400s on every other type","so not for Test Case Activity or Test Plan Summary","Summary statistics for a scheduled report, for THREE report types only","including every other type in ReportType","This is not the general report-read","optionally with sections"],"shape":"discovered","paginated":true},{"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 of the folder to search for"},{"name":"include_folder_hierarchy","type":"boolean","description":"Whether to include folder hierarchy in the response"}],"intent":"Get all folders and subfolders present in the projects.","guidance":["list root folders (paged with p","Returns all folders and subfolders in a project if it exists in TestHub"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"],"paginated":true},{"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":"Get already selected testcases for a tracebility report","guidance":["Retrieve the already selected testcases for a tracebility report"],"returns":["select_all","unique_test_case_count"]},{"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":"paginated","type":"boolean"}],"intent":"Get shared steps","guidance":["read one shared step with its ordered step details","Retrieves a list of shared steps for a project"],"returns":["id","title"],"paginated":true},{"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":"q[query]","type":"string"},{"name":"pre_fetch","type":"boolean"}],"intent":"Get all shared steps for a project","guidance":["Returns a list of all shared steps within a project"],"returns":["id","title","step_count","test_case_count"],"paginated":true},{"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"}],"intent":"THE source for a system field's option ids (priority/status/case_type/automation_state).","guidance":["a single attachment-URL field is enough to push the response past the ~30 KB cap","Supports q to search the option list and p to page it, which matters","a workspace can accumulate dozens of options on a single field"],"shape":"discovered","paginated":true},{"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":"Get all attachments for a test case in a folder in a project","guidance":["list a case's attachments","Retrieve all attachments associated with a specific test case within a folder","CURRENTLY RETURNING 500","Verified live against five different real test cases, with both the case's true folder_id and a mismatched one","a reproducible server error, not a bad request","Do NOT retry it and do not report it to the user as their mistake"],"returns":["id","byte_size","content_type","filename"]},{"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":"Get a test case by INTEGER id (v1, folder-scoped) \u2014 full detail + its TC-NNN identifier","guidance":["READ one case by INTEGER id (folder-scoped)","Read a test case by its INTEGER id","so for an integer id this v1 read is the ONLY way to fetch the case","Two jobs at once: (1) READ","NOT translation-only","Don't fall back to listing the folder to find the case: if you have the integer id, read it here directly"],"returns":["id","identifier","title"]},{"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":"Get test case count trend analytics","guidance":["Retrieve the trend of test case counts for a specific project"],"returns":["field"]},{"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","example":"folder_path"}],"intent":"Get Test Case Details","guidance":["full detail (steps, custom fields, issues) before editing","read this, improve, then write back"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"Get test case histories","guidance":["list all version records for a test case","Retrieve all history records for a specific test case"],"returns":["id","comment","created_at","entity_id","entity_type","group_id","project_id","source","user_id","version_id","version_name"]},{"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}],"intent":"Get test case history diff","guidance":["compare two versions","versions[] with exactly two ids (PAID plan)","Retrieve the diff of a specific history record for a test case"],"returns":["id","order","result","shared_step_id","step"]},{"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":"Get a specific test case history","guidance":["Retrieve details of a specific test case history by its ID"],"returns":["id","comment","created_at","entity_id","entity_type","group_id","project_id","source","user_id","version_id","version_name"]},{"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":"Get tags and metadata for a test case","guidance":["read a case's current tags","Retrieve the tags and metadata associated with a specific test case in a project"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"Get test case type split analytics","guidance":["Retrieve the split of test case types for a specific project"],"returns":["name","field","value","y"]},{"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"}],"query":[{"name":"required[fields]","type":"string","values":["count"]}],"intent":"Get paginated test cases for a test run","guidance":["page the cases in a run (each row is the case you log results against)","Retrieve a paginated list of test cases for a specific test run in a project"],"shape":"discovered"},{"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":"required[fields]","type":"string","example":"owner,priority"},{"name":"required[custom_fields]","type":"string","example":"121,119"},{"name":"all_folders","type":"string","values":["true"],"example":"true"}],"intent":"List a project's test cases \u2014 REQUIRES all_folders=true to be project-wide.","guidance":["Without all_folders=true this returns only ONE folder's cases","the project's first root folder","So a project-wide question answered from a bare call silently under-counts by whatever sits in every other folder, and nothing in the response says so","all_folders is compared as the STRING 'true' (all_folders = params['all_folders'] == 'true')","A JSON boolean true, 1, TRUE or any other value all fail that check and silently fall back to the single-folder scope"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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":"Get a test plan by INTEGER id (v1) \u2014 full detail + its TP-NNN identifier","guidance":["READ one plan by INTEGER id","Read a test plan by its INTEGER id (project integer id in the path)","Don't translate first just to read the plan","this returns its full detail directly","Two jobs at once: (1) READ","the plan's full detail (under test_plan"],"returns":["identifier","name"]},{"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"},{"name":"test_run_id","type":"integer"},{"name":"today","type":"string"}],"intent":"Execution-trend chart data for ONE plan or ONE run (XOR)","guidance":["execution-trend chart data for ONE plan or ONE run","pass test_plan_id XOR test_run_id (both or neither is a 400)","Time-series execution trend backing the Insights tab chart","Scope it with exactly one of test_plan_id or test_run_id","Passing both returns 400 \"Provide either test_plan_id or test_run_id, not both\"","passing neither returns 400 \"test_plan_id or test_run_id is required\""],"shape":"discovered"},{"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 a test plan","guidance":["chart data for the exploratory sessions linked to a plan","Aggregated chart data for exploratory sessions linked to the plan","the other half of the Insights tab","Like execution-trend, this is chart data only","insights widgets themselves are not a CRUD resource here"],"shape":"discovered"},{"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":"include_sub_test_plan_runs","type":"boolean"},{"name":"compact","type":"boolean"},{"name":"is_archived","type":"boolean"}],"intent":"List the test runs linked to a test plan","guidance":["list the runs a plan groups","Paginated list of the runs a plan groups","that field replaces the link set rather than appending to it","include_sub_test_plan_runs=true also returns runs belonging to the plan's sub-plans"],"shape":"discovered","paginated":true},{"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}],"intent":"Get paginated custom fields for test results","guidance":["Retrieve paginated list of custom fields configured for test results in a project"],"returns":["id","default_value","entity_type","field_name","field_type","field_user_name","is_bulk_editable","is_filterable","is_required","optional","placeholder"],"paginated":true},{"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"},{"name":"test_case_id","type":"integer","required":true}],"intent":"Get test results for a test case in a test run","guidance":["read a case's results within a run","Retrieve a list of test results for a specific test case within a test run in a project"],"returns":["id","status","backtrace","configuration_id","created_at","created_by_imported","custom_fields","description","error_description","expanded","failure_type","finished_at"]},{"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":["READ a run by INTEGER id","Read a test run by its INTEGER id (with the project's integer id in the path)","Don't translate first just to read the run","this returns its full detail directly","Two jobs at once: (1) READ","NOT translation-only"],"returns":["id","identifier","name","uuid"]},{"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},{"name":"test_run_id","type":"string","required":true}],"intent":"Get a run with its per-case rows (v1).","guidance":["the run with its per-case rows (did it pass?)"],"shape":"discovered"},{"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"}],"intent":"Get custom field values for test results in test runs","guidance":["Retrieve default field values and optionally custom fields for test results (TRTC - Test Run Test Case)"],"returns":["id","applies_to_all_projects","default_value","entity_type","field_name","field_type","is_required","link_to_future_projects","place_holder_text"]},{"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.","json_path":"/selection/select_all"},{"name":"unique_test_case_count","type":"integer","example":2,"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.","json_path":"/selection/folders"},{"name":"shared_selection","type":"object"}],"intent":"get selected test runs of a project","guidance":["Retrieve selected test runs for a specific project based on the provided selection criteria"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"ignore_run_type","type":"boolean"},{"name":"include_test_plan","type":"boolean"}],"intent":"Get all the test runs for a project","guidance":["list the project's (open) runs, paged with p","Retrieve a list of all test runs for a specific project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"Import a CSV dataset \u2014 NOT SUPPORTED in this profile","guidance":["NOT SUPPORTED in this profile","say CSV import isn't available","CSV import is not supported here","If asked to import a dataset from CSV, say it isn't available and point to the Test Management UI"]},{"method":"GET","path":"/api/v1/activities","mode":"read","entity":"activity","query":[{"name":"project_id","type":"integer"},{"name":"cursor","type":"string"},{"name":"limit","type":"integer"},{"name":"actor_id","type":"integer"},{"name":"starred_only","type":"boolean"},{"name":"entity_type","type":"array"}],"intent":"Read the activity feed (audit trail) \u2014 CURSOR paged, 15-day window","guidance":["WHO changed WHAT recently","group-wide audit trail","Group-wide audit trail of who changed what","Read-only: activity events cannot be created, edited, or deleted","Two things differ from every other list in this profile: - Cursor pagination, not page numbers","so you cannot jump to a page or read a total"],"returns":["no_starred_projects"]},{"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}],"intent":"List archived test plans","guidance":["where a 'missing' plan usually is, and the source of ids for bulk-retrieve","Paginated list of the project's archived plans","so if a plan the user names seems missing, look here before concluding it was deleted"],"shape":"discovered","paginated":true},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"List a dataset's options with their ids.","guidance":["list a dataset's options WITH their ids","the ids a filter or a test-case write needs"],"shape":"discovered"},{"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"},{"name":"q[uuid]","type":"string"}],"intent":"List datasets for a project.","guidance":["list datasets (paged p","Retrieve a paginated list of datasets for a project with optional filtering by name or UUID"],"returns":["name","created_at","rows_count","test_cases_count","updated_at","uuid"],"paginated":true},{"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":"entity_type","type":"string"},{"name":"entity_id","type":"integer"},{"name":"duplicates","type":"string"}],"intent":"List AI-suggested duplicate test cases \u2014 an empty list may mean the feature is OFF","guidance":["LIST the duplicate suggestions awaiting review","An EMPTY list may mean the feature is off","List the AI dedupe recommendations awaiting review in a project","Only duplicates with status suggested are returned","once merged, archived, or discarded they leave this list","An empty result is ambiguous"],"shape":"discovered","paginated":true},{"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}],"intent":"List defects for an exploratory session","guidance":["List defaults to active sessions","the parent project takes the INTEGER project id (v1)","defects are the issues linked to the session"],"returns":["issue_id","issue_type"],"paginated":true},{"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}],"intent":"List logs for an exploratory session","guidance":["page the session's log entries","Returns a paginated list of log entries for the specified exploratory session"],"returns":["id","status","content","created_at","elapsed_time","session_id","updated_at"],"paginated":true},{"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":"q[session_state]","type":"string","values":["active","closed"],"example":"active"},{"name":"q[assignee]","type":"integer","example":42},{"name":"q[owner]","type":"integer","example":42},{"name":"q[created_by]","type":"integer","example":10},{"name":"q[created_at_from]","type":"string","example":"2026-01-01"},{"name":"q[created_at_to]","type":"string","example":"2026-01-31"},{"name":"q[tags][]","type":"array","example":["regression","payment"]},{"name":"q[configuration_ids][]","type":"array","example":[1,2]},{"name":"q[search]","type":"string","example":"checkout"},{"name":"q[status]","type":"string","example":"pass"}],"intent":"List exploratory sessions for a project","guidance":["list sessions (defaults to active","Returns a paginated list of exploratory sessions"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"],"paginated":true},{"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","type":"string","required":true,"values":["testcase","testplan","testrun","exploratory_session","test_plan_test_case"]}],"intent":"List filters","guidance":["list saved filter views (optional entity = testcase|testplan|testrun|exploratory_session)","Retrieve a paginated list of saved filters for a project, optionally filtered by entity type"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"],"paginated":true},{"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":"List subfolders and folder metadata for a specific folder in a project","guidance":["one folder's detail + its direct subfolders + ancestors","Retrieve a list of subfolders and their metadata for a specific folder in a project"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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":"required[fields]","type":"string","example":"owner,priority"},{"name":"required[custom_fields]","type":"string","example":"121,119"}],"intent":"List the test cases in one folder","guidance":["Page through the cases directly inside a folder, by the folder's INTEGER id","Use this when the user has already named or selected a folder","Rows are verbose and list reads truncate around 30 KB"],"shape":"discovered","paginated":true},{"method":"GET","path":"/api/v1/projects","mode":"read","entity":"project","query":[{"name":"q","type":"string","example":"nishchay3"},{"name":"sort_by","type":"string"},{"name":"starred","type":"boolean"},{"name":"shared","type":"boolean"},{"name":"is_hidden_projects","type":"boolean","example":true}],"intent":"List projects for a group.","guidance":["Paginated list of the group's projects","query and page are not read by the server","so use this to FIND a project, not to enumerate them"],"returns":["id","identifier","name","created_at","description","import_id","project_creator","test_cases_count","test_runs_count"],"paginated":true},{"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"},{"name":"q[scheduled]","type":"string","values":["true","false"]}],"intent":"List all the scheduled reports","guidance":["list the project's report schedules (paged p","Retrieve a paginated list of schedules"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"],"paginated":true},{"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":"q","type":"string"}],"intent":"List test-case tags (paginated).","guidance":["list a project's test-case tags (paged with p, optional q)"],"shape":"discovered","paginated":true},{"method":"GET","path":"/api/v1/admin-v2/settings/templates","mode":"read","entity":"","query":[{"name":"entity_type","type":"string","values":["TestCase","TestResult","TestPlan","ExploratorySession"]},{"name":"project","type":"integer"},{"name":"name","type":"string"}],"intent":"List the workspace's test-case templates \u2014 THE source for `template_id`.","guidance":["Ids are per-workspace","so one carried over from another workspace (or from an example) produces the generic 400 \"The API request is invalid or improperly formed\" on a test-case create, with nothing naming the offending field","Call this when the user asked for a named template, or to confirm a template id before reusing one","One call therefore yields both fields","NOTE THE CASING: entity_type here is CamelCase (TestCase), unlike the hyphenated test-case used in path segments elsewhere","Passing test-case returns an empty list with a 200"],"shape":"discovered","paginated":true},{"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":"include_sub_plans","type":"boolean"},{"name":"status","type":"string","values":["new","started","completed"]},{"name":"sort_by","type":"string"},{"name":"minify","type":"boolean"}],"intent":"List test plans in a project (optionally including sub-plans)","guidance":["include_sub_plans=true to see sub-plans too","Paginated list of the project's test plans","This is how you find a plan's integer id before reading, updating, or deleting it","so never try to find a plan through search","Without it you see only top-level plans and a plan's children are invisible"],"shape":"discovered","paginated":true},{"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"]},{"name":"field_source","type":"string","values":["system","custom","all"]},{"name":"field_type","type":"string"},{"name":"q","type":"string"}],"intent":"THE canonical workspace field list (system + custom) \u2014 start here for definitions.","guidance":["workspace-wide list of DEFINITIONS across all projects","'does a field called X exist anywhere?'","The authoritative list (testhub-backed)","This is the authoritative definition list: it is backed by testhub, which owns custom-field definitions","That legacy collection reads a DEPRECATED table local to the Rails app that nothing else consults","its contents are unrelated to the real fields"],"shape":"discovered","paginated":true},{"method":"POST","path":"/api/v1/projects/{project_id}/tags/merge","mode":"write","entity":"tag","path_params":[{"name":"project_id","type":"integer","required":true}],"body":[{"name":"source_id","type":"integer","required":true},{"name":"target_id","type":"integer","required":true},{"name":"entity_type","type":"string","required":true,"values":["test_case","test_run","test_plan","shared_step"]}],"intent":"Merge one tag into another (v1). Admin-gated (tags_management).","guidance":["merge one tag into another ({ source_id, target_id, entity_type })"]},{"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","required":true,"example":123,"description":"ID of the new parent folder (internal APIs)"},{"name":"new_base_project_id","type":"integer","example":456,"description":"Optional destination project ID for cross-project moves"},{"name":"user_action","type":"string","example":"move_folder"}],"intent":"Move a test case folder to a different parent folder","guidance":["move a folder ({ new_base_folder_id, new_base_project_id })","cross-project moves run async","Move a test case folder to a new parent folder within the same project","This operation updates the folder's hierarchy without changing its contents"],"returns":["async","unique_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","required":true,"description":"Name of the new folder.","json_path":"/folder/name"},{"name":"notes","type":"string","description":"Description of the new folder.","json_path":"/folder/notes"}],"intent":"rename a folder in a project","guidance":["Rename an existing folder within a specific project"],"returns":["request_trace_id"]},{"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],"description":"List of folder IDs to reorder"},{"name":"prev","type":"integer","example":101,"description":"ID of the folder that will precede the moved folder"},{"name":"next","type":"integer","example":103,"description":"ID of the folder that will follow the moved folder"}],"intent":"Reorder folders in a project","guidance":["Reorder folders within a project by specifying the current order and optionally the previous and next folder IDs"],"returns":["request_trace_id"]},{"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":"ID of the test case that will be above the moved ones.","json_path":"/re_order/top_test_case"},{"name":"bottom_test_case","type":"string","description":"ID of the test case that will be below the moved ones.","json_path":"/re_order/bottom_test_case"},{"name":"test_cases","type":"array","required":true,"description":"Array of test case IDs to be moved.","json_path":"/re_order/test_cases"}],"intent":"Reorder test cases within a folder of a project","guidance":["reorder cases within a folder","Reorders test cases in a folder by placing them between top_test_case and bottom_test_case"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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. Passed in the BODY, not the path."},{"name":"merge_action","type":"string","required":true,"description":"`merge` folds source into target; any other value archives."},{"name":"source_testcase_id","type":"integer","description":"Required for merge \u2014 the case that will cease to exist independently."},{"name":"target_testcase_id","type":"integer","description":"Required for merge \u2014 the case that survives."}],"intent":"Resolve a duplicate by MERGING or ARCHIVING \u2014 destroys or hides a test case","guidance":["RESOLVE a suggestion","merge_action=merge folds source_testcase_id into target_testcase_id (DESTRUCTIVE to a test case), anything else archives","duplicate_id goes in the BODY","Act on one dedupe recommendation","merge_action selects what happens: - \"merge\"","folds source_testcase_id into target_testcase_id"]},{"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":"Restore a specific history entry","guidance":["roll the case back to this version (PAID plan","Restore a specific history entry by its ID"]},{"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":"duplicates","type":"string","description":"Duplicate-type filter."},{"name":"owner","type":"string","description":"Owner tab \u2014 scopes to the caller's own duplicates vs all."}],"intent":"Filtered search over suggested duplicates","guidance":["filtered search over suggestions (owner tab, duplicate type, test-case attributes)","Subject to the identical flag caveat: an empty list may mean the feature is off rather than that there are no duplicates"],"shape":"discovered","paginated":true},{"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"}],"query":[{"name":"q","type":"string"}],"intent":"Search group tags for an entity type","guidance":["search tags across the whole group (entity_type = test-case|test-run|test-plan)","Retrieve a paginated list of tags for the given entity type across the group (no project scoping)","used by the Global Search filter dropdowns"],"returns":["name","created_at","usage_count"],"paginated":true},{"method":"GET","path":"/api/v1/projects/{project_id}/{entity}/search","mode":"read","entity":"project","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"entity","type":"string","required":true,"values":["test-cases","test-runs","test-plans","reports"]}],"query":[{"name":"q[query]","type":"string","example":"tc"},{"name":"q[folder_id]","type":"integer","example":29529465},{"name":"use_bstack_id","type":"string","values":["0","1"]},{"name":"include_test_plan","type":"boolean"}],"intent":"Search for entities within a project","guidance":["Returns paginated results matching the search criteria"],"shape":"discovered","paginated":true},{"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":"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":["Note: Array values in the q parameter are automatically converted to comma-separated strings for compatibility with existing search logic"],"shape":"discovered","paginated":true},{"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"}],"intent":"Search users with access to a project","guidance":["Retrieves a paginated list of users who have access to the specified project","Returns user details including permissions, feature flags, and product roles"],"returns":["id","browserstack_user_id","customisable_dashboard_accessible","full_name","group_id","has_access","is_build_listing_enabled","is_delighted_visible","is_jira_app_project_panel_visible","is_lcnc_explore_dismissed","is_new_project_settings_visible","is_onboarding_project_header_visible"],"paginated":true},{"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"}],"intent":"Search test case tags","guidance":["Retrieves a paginated list of tags associated with test cases in the project","Returns both simple tag strings and detailed tag objects with metadata"],"returns":["name","created_at","usage_count"],"paginated":true},{"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":"query","type":"string"},{"name":"use_bstack_id","type":"string","values":["0","1"]}],"intent":"Search test cases in a project","guidance":["and see pagination-and-filtering for the key table and each value's source","there is NO top-level values array, and looking for one finds nothing","so it is routinely cut off and appears absent","NEVER probe candidate integers for an id","The four system option fields","a name silently returns 0 because the server matches an id column"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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","example":["qa-leads@company.com"],"description":"Recipient addresses. MUST be an array, even for one address."},{"name":"file_type","type":"array","example":["pdf"],"description":"Formats to attach. MUST be an array. Defaults to [\"pdf\"]."}],"intent":"Email a report immediately (async job).","guidance":["email the report immediately (async job)","Kicks off a background send","a 200 means the job was queued, NOT that mail was delivered","don't report it as sent","Both body fields are ARRAYS and are rejected with 400 \" should be an array\" if sent as scalars","Omitting emails sends to the report's configured recipients"]},{"method":"POST","path":"/api/v1/projects/{project_id}/folder/{folder_id}/undo-rm","mode":"write","entity":"folder","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"folder_id","type":"integer","required":true}],"intent":"Undo folder deletion","guidance":["Restores a previously deleted folder and returns its metadata along with path hierarchy"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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":"dataset_id","type":"integer","required":true},{"name":"option_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"body":[{"name":"option_value","type":"string","required":true,"description":"The option label."},{"name":"is_default","type":"boolean","required":true,"description":"REQUIRED \u2014 a missing value is a 400. Must be 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 parent option this hangs under."}],"intent":"Rename an option or change its default (POST to /update).","guidance":["NON-destructive, stored values follow the option id","Both option_value and is_default are required","a missing is_default is a 400","Renaming an option keeps the stored values pointing at it (the option id is unchanged)","so this is the NON-destructive way to fix a label"]},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"body":[{"name":"link_projects","type":"array","description":"Project INTEGER ids to ADD. Note the spelling \u2014 not `linked_projects`."},{"name":"unlink_projects","type":"array","description":"Project INTEGER ids to REMOVE. Destroys their stored values."},{"name":"link_to_future_projects","type":"boolean"},{"name":"select_all","type":"boolean","description":"Apply to every project; may switch the call to async."}],"intent":"Link or unlink projects for a dataset \u2014 how you change which projects see a field.","guidance":["CHANGE WHICH PROJECTS","Unlinking destroys that project's values","The keys here are link_projects and unlink_projects","Send the wrong spelling and it is silently dropped: 200, nothing changed","This is the single easiest mistake on this surface","These are DELTAS, not the complete new list: send only the projects to add and the projects to remove"]},{"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,"description":"Display label. Editable. REQUIRED even when unchanged."},{"name":"field_type","type":"string","required":true,"description":"REQUIRED for validation, but any change is silently ignored."},{"name":"is_required","type":"boolean","required":true,"description":"REQUIRED \u2014 omitting it is a 400."},{"name":"entity_type","type":"string"},{"name":"place_holder_text","type":"string"},{"name":"default_value","type":"string","description":"Only applied when `field_type` is `boolean`."}],"intent":"Update a field definition's label, requiredness or placeholder (POST to /update).","guidance":["Full replace: field_name + field_type + is_required all required","field_type changes are silently ignored","field_name, field_type and is_required must all be present","Omitting is_required is 400 \"Invalid Params\"","omitting field_name is a 500","field_name IS editable here"]},{"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","description":"Updated name of the dataset.","json_path":"/dataset/name"},{"name":"variables","type":"array","description":"Updated list of dataset variables/columns (max 40).","fields":[{"name":"name","type":"string","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/variables"},{"name":"rows","type":"array","description":"Updated list of dataset rows (max 100).","fields":[{"name":"row_number","type":"integer","required":true},{"name":"row_data","type":"array","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/rows"}],"intent":"Update a dataset.","guidance":["Update an existing dataset by its UUID with new variables and rows"],"returns":["name","created_at","rows_count","uuid"]},{"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 for the session.","json_path":"/exploratory_session/title"},{"name":"description","type":"string","description":"Updated description.","json_path":"/exploratory_session/description"},{"name":"charter","type":"string","description":"Updated testing charter.","json_path":"/exploratory_session/charter"},{"name":"timebox_duration","type":"integer","example":90,"description":"Updated planned duration in minutes.","json_path":"/exploratory_session/timebox_duration"},{"name":"duration","type":"integer","example":45,"description":"Tracked duration in minutes.","json_path":"/exploratory_session/duration"},{"name":"actual_duration","type":"integer","example":50,"description":"Final actual duration in minutes.","json_path":"/exploratory_session/actual_duration"},{"name":"folder_id","type":"integer","example":789,"description":"Move the session to a different folder.","json_path":"/exploratory_session/folder_id"},{"name":"owner","type":"integer","example":55,"description":"TCM user ID of the new owner / assignee.","json_path":"/exploratory_session/owner"},{"name":"assignee","type":"integer","example":55,"description":"Alias for `owner`.","json_path":"/exploratory_session/assignee"},{"name":"tags","type":"array","example":["smoke","payment"],"description":"Replace the session's tags with this list.","json_path":"/exploratory_session/tags"},{"name":"configurations","type":"array","example":[2,4],"description":"Replace the session's configuration IDs.","json_path":"/exploratory_session/configurations"},{"name":"attachments","type":"array","description":"Updated set of blob / media attachment IDs.","json_path":"/exploratory_session/attachments"},{"name":"issues","type":"array","description":"Replace the linked issues for this session.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/exploratory_session/issues"}],"intent":"Update an exploratory session","guidance":["edit a session (title, charter, timebox_duration, duration, owner, tags, ...)","Updates one or more fields of an exploratory session","Time fields (timebox_duration, duration, actual_duration) must be non-negative integers not exceeding 2147483647"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"]},{"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":"Updated rich-text HTML content of the log entry.","json_path":"/session_log/content"},{"name":"status","type":"string","values":["pass","fail","bug","retest","block","note"],"example":"fail","description":"Updated test result status.","json_path":"/session_log/status"},{"name":"elapsed_time","type":"integer","example":180,"description":"Updated elapsed time in seconds.","json_path":"/session_log/elapsed_time"},{"name":"defects","type":"array","description":"Replace the linked defects for this log entry.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/session_log/defects"}],"intent":"Update a session log entry","guidance":["Updates an existing log entry within an exploratory session","Rich-text HTML content is sanitised before storage"],"returns":["id","status","content","created_at","elapsed_time","session_id","updated_at"]},{"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,"description":"Name of the filter"},{"name":"entity","type":"string","required":true,"values":["testcase","testplan","testrun","exploratory_session","test_plan_test_case"],"description":"Entity type the filter applies to. The server's validator accepts five values (the\nlast two were missing here); an unlisted value returns 400 \"Invalid JSON data\"."},{"name":"is_project_level","type":"boolean","description":"Whether this filter is available at project level"},{"name":"filters","type":"object","description":"Filter criteria object containing the actual filter conditions"}],"intent":"Update a filter","guidance":["Update an existing saved filter with new configuration or filter criteria"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"]},{"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"},{"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 these are ids, not names).","json_path":"/test_result/status_id"},{"name":"description","type":"string","description":"Rich text description of the test result.","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's 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":"Update the latest test result","guidance":["update the LATEST result for a case in a run (partial, same body shape)","Update the most recent test result for a specific test case within a test run in a project","Optionally filter by configuration_id or mapping_id"],"returns":["id","name","byte_size","checksum","content_type","download_url","key","product_id","size","url"]},{"method":"POST","path":"/api/v1/projects/{project_id}/settings","mode":"write","entity":"project","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Update project settings","guidance":["Accepts an object with setting keys as properties and boolean values"]},{"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","example":10000},{"name":"report_type","type":"string","required":true,"values":["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","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_statu"},{"name":"title","type":"string","example":"Updated Title"},{"name":"description","type":"string","example":""},{"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 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`"},{"name":"report_creation_mode","type":"string","required":true,"values":["custom_report","system_generated"],"example":"custom_report"},{"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"},{"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":"Integer run ids \u2014 pairs with report_timeframe=specific_test_runs.","json_path":"/report_filters/test_runs"},{"name":"test_plans","type":"array","description":"Integer plan ids \u2014 pairs with report_timeframe=specific_test_plans.","json_path":"/report_filters/test_plans"},{"name":"test_cases","type":"string","description":"Case selection \u2014 pairs with report_timeframe=specific_test_cases. FOLDER-KEYED\n(see SelectionData), never a flat id list: the server resolves the selection\nthrough its folder map, so any other shape yields zero cases and saves an\nUNSCOPED, project-wide report at 200.\n\nSend all three keys. Folder ids are STRING keys; test-case ids are INTEGERS\n(the numeric `id`, not the TC-NNN identifier) \u2014 take bo","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":"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.","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 } }.","json_path":"/report_filters/test_plan_selection"},{"name":"builds","type":"array","json_path":"/report_filters/builds"},{"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\n`Test Run Summary`, `Test Run Detailed Report` and `Test Case Activity` \u2014 on\nthe other six report types the server never reads it and drops it silently.\nPersisted (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`."},{"name":"users","type":"array","json_path":"/mail_to/users"},{"name":"external_mails","type":"array","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\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"},{"name":"time","type":"string","example":"08:00","description":"24-hour HH:MM, UTC.","json_path":"/frequency_details/time"},{"name":"day","type":"string","example":"monday","description":"Required for weekly (lowercase day name) and monthly (integer 1-28).\nOmit for daily.","json_path":"/frequency_details/day"}],"intent":"Update a scheduled report","guidance":["FULL REPLACE, not a delta: omitted fields are nulled and dropping frequency cancels the schedule","Update the configuration of an existing scheduled report for a project"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"]},{"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":"Title of the shared step"},{"name":"folder_id","type":"integer","description":"ID of the shared-field folder to move the shared step into. Null moves it to the root (Unassigned)."},{"name":"shared_step_details","type":"array","required":true,"fields":[{"name":"id","type":"integer"},{"name":"step","type":"string"},{"name":"result","type":"string"},{"name":"order","type":"integer"},{"name":"test_data","type":"string"}]}],"intent":"Update a shared step","guidance":["update a shared step","title and shared_step_details are BOTH required, and the details array REPLACES the existing steps","Updates an existing shared step in a project"],"returns":["id","title"]},{"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"},{"name":"description","type":"string","json_path":"/test_plan/description"},{"name":"start_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/start_date"},{"name":"end_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/end_date"},{"name":"plan_status","type":"string","description":"Plan status. The list filter accepts new / started / completed.","json_path":"/test_plan/plan_status"},{"name":"owner","type":"integer","description":"Owner user id \u2014 resolve via the project users read first.","json_path":"/test_plan/owner"},{"name":"parent_plan_id","type":"integer","description":"Parent plan's INTEGER id. Set it to create (or re-parent into) a SUB-plan \u2014 the\nresult carries an STP-NNN identifier. Null/omitted means a top-level plan.","json_path":"/test_plan/parent_plan_id"},{"name":"tags","type":"array","json_path":"/test_plan/tags"},{"name":"reviewers","type":"array","description":"Reviewer user ids.","json_path":"/test_plan/reviewers"},{"name":"attachments","type":"array","description":"Attachment ids already uploaded via the attachments surface.","json_path":"/test_plan/attachments"},{"name":"custom_fields","type":"object","json_path":"/test_plan/custom_fields"},{"name":"issues","type":"array","description":"Linked tracker issues. Structured \u2014 NOT a flat array of keys.","fields":[{"name":"issue_id","type":"string"},{"name":"issue_type","type":"string"},{"name":"metadata","type":"object"}],"json_path":"/test_plan/issues"},{"name":"test_runs","type":"string","description":"The plan's linked test runs. Accepts EITHER an array of test-run integer ids, OR a\nbulk-selection object for \"every run matching this filter\". On update this REPLACES\nthe existing link set rather than appending.","json_path":"/test_plan/test_runs"}],"intent":"Update a test plan (or sub-plan)","guidance":["Update a plan by its INTEGER id","Takes the same body as create","so it also works on a sub-plan by its own id","clearing it promotes a sub-plan to a top-level plan","do that only when the user actually asked to move it in the hierarchy","Send only the fields you intend to change"]},{"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 AI-generated or AI-processed attachments","guidance":["upload a file as AI CONTEXT (restricted MIME types, smaller size cap) to seed test-case content","Files are stored in S3 with pre-signed URLs","File Storage: Files uploaded to S3 with pre-signed URLs (expires in ~26 minutes)"],"returns":["id","name","content_type","download_url","size","url"]},{"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 generic attachments to a project from rich text editor or other sources","guidance":["UPLOAD file blob(s) (multipart)","Uploads one or more files as generic attachments to a project","Files are stored in S3 and pre-signed URLs are returned for both viewing and downloading"],"returns":["id","name","content_type","download_url","size","url"]},{"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":"Upload one or more attachments to a test case","guidance":["Upload one or more attachments to a specific test case within a folder in a project"],"returns":["id","byte_size","content_type","filename"]},{"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 whose per-test-case / per-run / per-day breakdown is requested."}],"intent":"Fetch the User Workload drill-down for a single user","guidance":["User-Workload per-user execution breakdown","Only valid on a report whose type is User Workload Report","every other type returns 400 \"Drill-down is only available for User Workload Reports\""],"shape":"discovered","paginated":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},"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)","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_v1"]},"attachment":{"entity":"attachment","title":"Attachments","aliases":["attachment","file","attachments","blob"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"attached to"},{"entity":"result","via":"attached to"}],"capabilities":["delete_test_case_attachment","get_test_case_attachments_v1","upload_a_i_attachments","upload_generic_attachments","upload_test_case_attachments_v1"]},"configuration":{"entity":"configuration","title":"Configurations","aliases":["config","configuration","environment","browser","os","device"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_run","via":"case status tracked against"}],"capabilities":["create_configuration_v1","get_configurations_v1"]},"custom_field":{"entity":"custom_field","title":"Custom fields & system fields","aliases":["custom field","custom fields","field","system field"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"extends"},{"entity":"result","via":"extends"}],"capabilities":["create_custom_field_dataset_option_v2","create_custom_field_dataset_v2","create_custom_field_definition_v2","delete_custom_field_dataset_option_v2","delete_custom_field_dataset_v2","delete_custom_field_definition_v2","get_custom_field_dataset_v2","get_custom_field_definition_v2","get_custom_field_values_v1","get_project_id_custom_fields_v2_v1","get_test_results_custom_fields_v1","list_custom_field_dataset_options_v2","list_workspace_fields_v2","update_custom_field_dataset_option_v2","update_custom_field_dataset_project_mapping_v2","update_custom_field_definition_v2"]},"dataset":{"entity":"dataset","title":"Dataset","aliases":["datasets","data-driven-data","dds"],"id_convention":"uuid","parents":["project"],"relations":[{"entity":"test_case","via":"drives"},{"entity":"variable","via":"has columns"}],"capabilities":["create_dataset","delete_dataset","get_dataset","get_dataset_linked_test_cases","get_dataset_variables","import_dataset_c_s_v","list_datasets","update_dataset"]},"duplicate":{"entity":"duplicate","title":"Duplicate recommendation (AI dedupe)","aliases":["duplicate","duplicates","dedupe","deduplication","duplicate recommendation","ai duplicates","redundant test cases"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"links the pair it believes are duplicates"}],"capabilities":["discard_duplicate_v1","get_dedupe_status_v1","get_duplicate_source_test_case_v1","get_duplicate_v1","list_duplicates_v1","resolve_duplicate_v1","search_duplicates_v1"]},"exploratory_session":{"entity":"exploratory_session","title":"Exploratory session","aliases":["session","exploratory","et-session"],"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_v1","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"]},"filter":{"entity":"filter","title":"Saved filter view","aliases":["filter","filters","saved view","saved 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"]},"folder":{"entity":"folder","title":"Folder","aliases":["dir","directory"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"folder","via":"nests under"},{"entity":"test_case","via":"organises"}],"capabilities":["copy_folder","create_bulk_test_cases_v1","create_root_folder_v1","create_sub_folder_v1","delete_folder_and_contents","get_folder_remove_summary","get_folder_tree_v1","get_root_folders_v1","list_folder_contents","move_folder_v1","rename_folder_v1","reorder_folders","undo_remove_folder"]},"project":{"entity":"project","title":"Project","aliases":["pr","workspace"],"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_v1","export_dashboard_analytics","get_active_test_runs_info_v1","get_automation_stats_v1","get_closed_test_runs_info_v1","get_closed_test_runs_split_v1","get_entity_filter_details_v1","get_issues_count_info_v1","get_project_by_integer_id_v1","get_project_settings","get_project_users_v2_v1","get_projects_basic_v1","get_projects_minify_v1","get_test_case_count_trend_v1","get_test_case_type_split_v1","list_projects_v1","search_project_entities","update_project_settings"]},"report":{"entity":"report","title":"Report","aliases":["reports","scheduled-report","schedule","analytics-report"],"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","delete_report_schedule","download_report","get_exploratory_session_summary_v1","get_report_attachment_url","get_report_detail","get_report_section_v1","get_report_summary_by_type","get_selected_report_testcases","list_schedules","send_report_email_now_v1","update_report","user_workload_drilldown"]},"result":{"entity":"result","title":"Result","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_v1","create_step_result_v1","create_test_result_for_test_case","delete_test_result_v1","get_test_results_for_test_case","update_latest_test_result_for_test_case"]},"shared_step":{"entity":"shared_step","title":"Shared step","aliases":["shared step","shared steps","reusable step","step template"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"reused by"}],"capabilities":["create_shared_step_v1","delete_shared_step_v1","get_shared_steps_by_i_d_v1","get_shared_steps_v1","update_shared_step_v1"]},"tag":{"entity":"tag","title":"Tag","aliases":["tags","label","labels"],"id_convention":"name","parents":["project"],"relations":[{"entity":"test_case","via":"labels"},{"entity":"test_run","via":"labels"},{"entity":"test_plan","via":"labels"}],"capabilities":["bulk_edit_test_cases_v2","get_test_case_tags","list_test_case_tags_v1","merge_tags_v1","search_group_tags","search_test_case_tags"]},"test_case":{"entity":"test_case","title":"Test case","aliases":["tc","case","test case"],"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":["bulk_archive_test_cases_by_project","bulk_copy_test_cases","bulk_delete_test_cases","bulk_edit_test_cases","bulk_edit_test_cases_in_test_run","bulk_move_test_cases","bulk_retrieve_test_cases","create_test_case_v1","create_test_cases","edit_test_case_partial_v1","edit_test_case_v1","get_archived_test_cases","get_system_field_values_v1","get_test_case_by_integer_id_v1","get_test_case_detail","get_test_cases_v1","list_folder_test_cases_v1","reorder_test_cases_by_folder_v1","search_project_entities_v2","search_test_cases_by_folder_v1"]},"test_plan":{"entity":"test_plan","title":"Test plan (and sub-plan)","aliases":["tp","plan","milestone","sub test plan","subplan","stp"],"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_v1","bulk_retrieve_test_plans_v1","clone_test_plan_v1","count_test_plan_test_runs_v1","create_test_plan_v1","delete_test_plan_v1","get_archived_test_plan_count_v1","get_test_plan_by_integer_id_v1","get_test_plan_execution_trend_v1","get_test_plan_linked_sessions_chart_data_v1","get_test_plan_test_runs_v1","list_archived_test_plans_v1","list_test_plans_v1","update_test_plan_v1"]},"test_run":{"entity":"test_run","title":"Test run","aliases":["tr","run","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":["assign_test_run_owner","bulk_assign_test_cases_to_test_run","clone_test_run","close_test_run_v1","count_run_selection_v1","create_test_run_v1","delete_v1_test_run","edit_test_run","get_closed_test_runs","get_test_cases_for_v1_test_run","get_test_run_by_integer_id_v1","get_test_run_cases_v1","get_test_runs_form_fields_v1","get_test_runs_selection","get_test_runs_v1"]},"user":{"entity":"user","title":"User","aliases":["users","member","assignee","owner"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"project","via":"member of"},{"entity":"test_run","via":"assigned to"},{"entity":"test_case","via":"owns"}],"capabilities":["get_group_users_v2_v1","search_project_users"]},"version":{"entity":"version","title":"Test case version","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":["get_test_case_histories_v1","get_test_case_history_diff_v1","get_test_case_history_v1","restore_history_v1"]}}}}} \ No newline at end of file diff --git a/package.json b/package.json index 4f01ec5..cad0a86 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,8 @@ "browserstack-mcp-server": "dist/index.js" }, "files": [ - "dist" + "dist", + "capability-index.json" ], "keywords": [ "mcp", diff --git a/src/server-factory.ts b/src/server-factory.ts index a5a926d..b469021 100644 --- a/src/server-factory.ts +++ b/src/server-factory.ts @@ -20,6 +20,7 @@ import addBuildInsightsTools from "./tools/build-insights.js"; import { setupOnInitialized } from "./oninitialized.js"; import { BrowserStackConfig } from "./lib/types.js"; import addRCATools from "./tools/rca-agent.js"; +import addCapabilityRegistryTools from "./tools/capability-registry/register.js"; /** * Wrapper class for BrowserStack MCP Server @@ -61,6 +62,10 @@ export class BrowserStackMcpServer { addSelfHealTools, addBuildInsightsTools, addRCATools, + // Driven by a prebuilt index rather than hand-written per endpoint. Registers + // nothing (and logs why) when the artifact is absent, so a packaging problem cannot + // take the other products' tools down with it. + addCapabilityRegistryTools, ]; toolAdders.forEach((adder) => { diff --git a/src/tools/capability-registry/config.ts b/src/tools/capability-registry/config.ts new file mode 100644 index 0000000..2f3cd18 --- /dev/null +++ b/src/tools/capability-registry/config.ts @@ -0,0 +1,44 @@ +/** + * Where the index comes from, and where each product lives. + * + * `base_url` is deliberately NOT in the artifact — it is environment-specific, so the same + * artifact ships everywhere and the host is resolved here instead. + */ + +import { existsSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join, resolve } from "node:path"; + +const PROD_HOSTS: Record = { + tm: "https://test-management.browserstack.com", + a11y: "https://accessibility.browserstack.com", + tra: "https://test-reporting.browserstack.com", +}; + +/** Per-product override, e.g. CAPABILITY_REGISTRY_BASE_URL_TM=https://…-preprod.bsstag.com */ +export function baseUrlFor(product: string): string { + const override = process.env[`CAPABILITY_REGISTRY_BASE_URL_${product.toUpperCase()}`]; + return (override || PROD_HOSTS[product] || "").replace(/\/$/, ""); +} + +/** + * Locate the artifact. Explicit env wins; otherwise look beside the compiled module and + * then at the package root, because `tsc` compiles TS and does not copy JSON into `dist`. + */ +export function indexPath(): string | undefined { + const configured = process.env.CAPABILITY_REGISTRY_INDEX; + if (configured) return existsSync(configured) ? resolve(configured) : undefined; + + const here = dirname(fileURLToPath(import.meta.url)); + const candidates = [ + join(here, "registry-index.json"), + join(here, "..", "..", "..", "capability-index.json"), // dist/ -> package root + join(here, "..", "..", "..", "..", "capability-index.json"), // src/ -> package root + ]; + return candidates.find((candidate) => existsSync(candidate)); +} + +/** Off by default is wrong for a shipped feature, but a kill switch is not. */ +export function isEnabled(): boolean { + return (process.env.CAPABILITY_REGISTRY_DISABLED || "").toLowerCase() !== "true"; +} diff --git a/src/tools/capability-registry/index-loader.ts b/src/tools/capability-registry/index-loader.ts index ad325cd..33000fe 100644 --- a/src/tools/capability-registry/index-loader.ts +++ b/src/tools/capability-registry/index-loader.ts @@ -5,6 +5,7 @@ import { readFileSync } from "node:fs"; import { Capability, + PagingRule, RegistryIndex, SUPPORTED_SCHEMA_VERSION, } from "./types.js"; @@ -53,6 +54,12 @@ export class CapabilityRegistry { return this.index.build_id; } + /** Paging controls for one endpoint, or an empty rule when it does not page. */ + pagingFor(product: string, capability: Capability): PagingRule { + const rules = this.index.products[product]?.paging || {}; + return rules[endpointKey(capability.method, capability.path)] || {}; + } + productNames(): string[] { return Object.keys(this.index.products).sort(); } diff --git a/src/tools/capability-registry/register.ts b/src/tools/capability-registry/register.ts index 0ef3dc7..7508ec2 100644 --- a/src/tools/capability-registry/register.ts +++ b/src/tools/capability-registry/register.ts @@ -8,11 +8,15 @@ * read/write tool pair was buying. */ -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { McpServer, RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js"; import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; +import logger from "../../logger.js"; +import { trackMCP } from "../../lib/instrumentation.js"; +import { BrowserStackConfig } from "../../lib/types.js"; import { GroupedArguments } from "./bind.js"; +import { baseUrlFor, indexPath, isEnabled } from "./config.js"; import { Credentials, Transport, fetchTransport } from "./egress.js"; import { CapabilityRegistry, InvocationError } from "./index-loader.js"; import { invoke } from "./resolve.js"; @@ -29,6 +33,55 @@ export interface RegistryDeps { transport?: Transport; } +/** + * The tool-adder the server factory calls. + * + * Registers NOTHING when the artifact is absent or unreadable, rather than throwing: a + * missing index is a packaging problem, and taking the whole MCP server down with it would + * remove every other product's tools too. The reason is logged so it is not silent. + */ +export function addCapabilityRegistryToolsFromConfig( + server: McpServer, + config: BrowserStackConfig, +): Record { + if (!isEnabled()) { + logger.info("capability registry disabled by CAPABILITY_REGISTRY_DISABLED"); + return {}; + } + const file = indexPath(); + if (!file) { + logger.warn( + "capability registry index not found; its tools are not registered. Set " + + "CAPABILITY_REGISTRY_INDEX or ship capability-index.json at the package root.", + ); + return {}; + } + let registry: CapabilityRegistry; + try { + registry = CapabilityRegistry.fromFile(file); + } catch (error) { + logger.error( + "capability registry index at %s is unusable: %s", + file, error instanceof Error ? error.message : String(error), + ); + return {}; + } + logger.info( + "capability registry loaded: build %s, %d product(s)", + registry.buildId, registry.productNames().length, + ); + return addCapabilityRegistryTools(server, { + registry, + baseUrlFor, + // Read per call, not captured: the remote server rebuilds config per session, so a + // captured credential would outlive the session it belongs to. + credentialsFor: () => ({ + username: config["browserstack-username"], + accessKey: config["browserstack-access-key"], + }), + }, config); +} + function ok(payload: unknown): CallToolResult { return { content: [{ type: "text", text: JSON.stringify(payload) }] }; } @@ -37,36 +90,54 @@ function failed(message: string): CallToolResult { return { content: [{ type: "text", text: JSON.stringify({ ok: false, error: message }) }], isError: true }; } -export function addCapabilityRegistryTools(server: McpServer, deps: RegistryDeps): void { +export function addCapabilityRegistryTools( + server: McpServer, + deps: RegistryDeps, + config?: BrowserStackConfig, +): Record { const { registry } = deps; const transport = deps.transport || fetchTransport(); + const tools: Record = {}; - server.tool( + /** Instrumentation in the house style, and never fatal to the call it wraps. */ + const track = (name: string) => { + try { + trackMCP(name, server.server.getClientVersion()!, undefined, config); + } catch { + // Telemetry must not decide whether a tool call succeeds. + } + }; + + tools.listProducts = server.tool( "listProducts", "List the BrowserStack products this surface can reach, with a one-line summary each. " + "Start here when you do not know which product a task belongs to.", {}, - async () => ok({ + async () => { + track("listProducts"); + return ok({ build_id: registry.buildId, products: registry.productNames().map((name) => ({ name, summary: registry.index.products[name].summary, })), - }), + }); + }, ); - server.tool( + tools.listEntities = server.tool( "listEntities", "List the entities a product models (test case, folder, test plan, …). Use it to scope " + "searchCapability, or to find the entity name describeEntity wants.", { product: z.string().describe("Product name from listProducts.") }, async ({ product }) => { + track("listEntities"); const bundle = registry.index.products[product]; if (!bundle) return failed(`unknown product '${product}'`); return ok({ product, entities: Object.keys(bundle.entities).sort() }); }, ); - server.tool( + tools.describeEntity = server.tool( "describeEntity", "Describe one entity: what it is, what identifies it, what it relates to, and the " + "vocabulary the product uses for it. Read this before filtering or writing, because " + @@ -76,6 +147,7 @@ export function addCapabilityRegistryTools(server: McpServer, deps: RegistryDeps entity: z.string().describe("Entity name from listEntities."), }, async ({ product, entity }) => { + track("describeEntity"); const bundle = registry.index.products[product]; if (!bundle) return failed(`unknown product '${product}'`); const doc = bundle.entities[entity]; @@ -88,7 +160,7 @@ export function addCapabilityRegistryTools(server: McpServer, deps: RegistryDeps }, ); - server.tool( + tools.searchCapability = server.tool( "searchCapability", "Find endpoints this surface can call, by plain language, optionally narrowed to one " + "entity, product or mode. Each result carries the endpoint's `method` and `path` plus " + @@ -104,15 +176,18 @@ export function addCapabilityRegistryTools(server: McpServer, deps: RegistryDeps .describe("Restrict to reads or writes. Omit to let the query decide."), limit: z.number().optional().describe("Max results (default 8)."), }, - async ({ query, entity, product, mode, limit }) => ok({ + async ({ query, entity, product, mode, limit }) => { + track("searchCapability"); + return ok({ build_id: registry.buildId, ...searchCapabilities(registry.index.products, query, { entity, product, mode: mode as Mode | undefined, limit, }), - }), + }); + }, ); - server.tool( + tools.invokeEndpoint = server.tool( "invokeEndpoint", "Call an endpoint returned by searchCapability. Pass `method` and `path` exactly as " + "given, with arguments grouped into path_params / query / body under the spec's own " + @@ -137,6 +212,7 @@ export function addCapabilityRegistryTools(server: McpServer, deps: RegistryDeps top_n: z.number().optional().describe("Keep only the first N rows after ordering."), }, async (input): Promise => { + track("invokeEndpoint"); try { const { product, capability } = registry.byEndpointLookup( input.method, input.path, input.product, @@ -177,14 +253,18 @@ export function addCapabilityRegistryTools(server: McpServer, deps: RegistryDeps const result = await invoke( capability, args, deps.baseUrlFor(product), deps.credentialsFor(), transport, { orderBy: input.order_by, topN: input.top_n }, + registry.pagingFor(product, capability), ); return ok(result); } catch (error) { if (error instanceof InvocationError) return failed(error.message); - throw error; + logger.error("invokeEndpoint failed: %s", error instanceof Error ? error.message : String(error)); + return failed("that endpoint could not be invoked"); } }, ); + + return tools; } -export default addCapabilityRegistryTools; +export default addCapabilityRegistryToolsFromConfig; diff --git a/src/tools/capability-registry/resolve.ts b/src/tools/capability-registry/resolve.ts index f421534..ec1f986 100644 --- a/src/tools/capability-registry/resolve.ts +++ b/src/tools/capability-registry/resolve.ts @@ -6,7 +6,7 @@ import { bind, GroupedArguments } from "./bind.js"; import { itemsOf, projectRow, rowFields, totalOf } from "./envelope.js"; import { authHeaders, Credentials, Transport } from "./egress.js"; import { InvocationError } from "./index-loader.js"; -import { Capability } from "./types.js"; +import { Capability, PagingRule } from "./types.js"; /** A backstop, not a budget: a runaway pager is a bug, and 60 pages is past any real read. */ export const MAX_PAGES = 60; @@ -28,16 +28,6 @@ export interface InvokeOptions { pageSize?: number; } -function pagingParams(capability: Capability): { page?: string; size?: string; max: number } { - // The artifact hides the page/count parameter NAMES (they are the resolver's, not the - // caller's) but publishes `paginated` and `max_items`. tm's own convention is `p` for the - // page and `count`/`per_page` for the size, and the declared ceiling travels as max_items. - if (!capability.paginated) return { max: 0 }; - const query = new Set((capability.query || []).map((param) => param.name)); - const size = ["count", "per_page", "page_size"].find((name) => query.has(name)); - return { page: query.has("p") ? "p" : query.has("page") ? "page" : undefined, size, max: capability.max_items || 0 }; -} - export async function invoke( capability: Capability, args: GroupedArguments, @@ -45,11 +35,11 @@ export async function invoke( credentials: Credentials, transport: Transport, options: InvokeOptions = {}, + paging: PagingRule = {}, ): Promise { if (!baseUrl) throw new InvocationError("no base URL is configured for that product"); const bound = bind(capability, args); const headers = authHeaders(credentials); - const paging = pagingParams(capability); const items: Record[] = []; const declared = rowFields(capability.returns); @@ -65,10 +55,8 @@ export async function invoke( if (paging.page) query[paging.page] = page; // Ask for the largest page the operation declares. Paging at the product's default was // the 17 Aug failure: 880 projects walked 30 at a time. - if (paging.size && !(paging.size in query)) { - query[paging.size] = options.pageSize || paging.max || undefined; - if (query[paging.size] === undefined) delete query[paging.size]; - } + const size = options.pageSize || paging.max; + if (paging.size && size && !(paging.size in query)) query[paging.size] = size; const response = await transport( capability.method, `${baseUrl.replace(/\/$/, "")}${bound.path}`, diff --git a/src/tools/capability-registry/types.ts b/src/tools/capability-registry/types.ts index dcc84d7..e082eed 100644 --- a/src/tools/capability-registry/types.ts +++ b/src/tools/capability-registry/types.ts @@ -62,10 +62,27 @@ export interface EntityDoc { [key: string]: unknown; } +/** + * Paging controls, keyed "METHOD /path". + * + * These live BESIDE the capabilities rather than on them, and deliberately. The page and + * size parameter names are the resolver's, not the caller's — publishing them invites a + * caller to drive paging itself, which is what burned the call budget on 17 Aug. Whoever + * RUNS the request still needs them, so they travel here: `resolve` reads this map and the + * search tool never echoes it, leaving the published surface unchanged. + */ +export interface PagingRule { + page?: string; + size?: string; + /** The largest page the operation declares. Absent when the spec states no maximum. */ + max?: number; +} + export interface ProductIndex { summary: string; capabilities: Capability[]; entities: Record; + paging?: Record; } export interface RegistryIndex { diff --git a/tests/fixtures/registry-index.json b/tests/fixtures/registry-index.json index 73efebb..6a8157d 100644 --- a/tests/fixtures/registry-index.json +++ b/tests/fixtures/registry-index.json @@ -1 +1 @@ -{"schema_version":1,"build_id":"3c872d0b7b-173caps-e0e55b7","harness_commit":"e0e55b7e","products":{"tm":{"summary":"BrowserStack Test Management API (v1 \u2014 the SSO/OAuth app API, cookie-authenticated).","capabilities":[{"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"}],"body":[{"name":"owner","type":"integer","description":"The ID of the user to assign as the owner of the test run."}],"intent":"Assign a owner to the test run","guidance":["Assign a user as the owner of a specific test run within a project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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, verified live: 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":"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":["Archive multiple test cases within a project","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"paginated":true},{"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,"description":"Plan INTEGER ids."}],"intent":"Archive test plans in bulk (reversible \u2014 prefer this over delete)","guidance":["REVERSIBLY remove plans from view","Async above the bulk threshold","Soft-archive one or more plans","reach for it whenever the user wants plans \"removed\" or \"cleaned up\" without saying they must be destroyed"],"returns":["async","unique_id"]},{"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"}],"query":[{"name":"include_subfolders_tc","type":"boolean"}],"body":[{"name":"assignee_id","type":"integer","example":2,"description":"ID of the assignee to whom the test cases will be assigned"},{"name":"mapping_ids","type":"array","example":[999,991,1024,1019],"description":"List of test case IDs to be linked"},{"name":"select_all","type":"boolean","example":false,"description":"Flag to select all test cases"},{"name":"de_selected_ids","type":"array","description":"List of test case IDs to be de-selected"},{"name":"folder_id","type":"integer","description":"ID of the folder to which the test cases belong"}],"intent":"Bulk assign test cases to a test run","guidance":["assign specific cases within a run to a user (async above 300)","Assign multiple test cases to a specific test run within a project, allowing for bulk operations on test case assignments"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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` \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, verified live: an unrecognised key here is SILENTLY DROPPED, so a typo widens the ta"},{"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,"json_path":"/test_case/ids"},{"name":"de_selected_ids","type":"array","required":true,"json_path":"/test_case/de_selected_ids"},{"name":"select_all","type":"boolean","required":true,"json_path":"/test_case/select_all"},{"name":"folder_id","type":"string","description":"SOURCE folder that scopes the selection, at top level. OPTIONAL when you pass explicit `ids` (verified live: the copy succeeds without it), but send it whenever `select_all` is true \u2014 it is what bounds the set, and `select_all` with `folder_id` and no `q` copies exactly that folder's cases. Integer and string are both accepted; the declared string matches what the UI sends here, which unlike bulk-"},{"name":"destination_folder_id","type":"integer","required":true,"description":"Target folder for the copies, and the one genuinely required destination field \u2014 omitting it is a bare `400`, verified live. NOTE it is TOP-LEVEL for copy, unlike bulk-move where the destination sits inside `test_case`."},{"name":"destination_project_id","type":"string","description":"Target project id. Needed only for a copy to ANOTHER project. For a copy WITHIN one project it is OPTIONAL \u2014 verified live, omitting it copies correctly into `destination_folder_id`, and integer and string are both accepted. The product does both: its Copy/Move modal sends the source project id, its clone / drag-and-drop path omits it. Send the source id to be explicit or omit it; never invent a v"}],"intent":"Bulk copy test cases","guidance":["copy a selection to a folder (async above 30)","Bulk copy test cases from one folder to another within a project","This operation allows you to copy multiple test cases at once, which can be useful for organizing or duplicating test cases across different folders","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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` \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, verified live: 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":"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 delete.","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":"Delete every case in scope, minus `de_selected_ids`.","json_path":"/test_case/select_all"}],"intent":"Bulk Delete Test Cases from Search","guidance":["All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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."}],"intent":"Delete test results in bulk \u2014 PARTIAL SUCCESS is normal, always read `skipped`","guidance":["PARTIAL SUCCESS is normal, always read the skipped array back","Authorisation is enforced per id (a caller must be the result's author or a project admin)","treating a 200 as \"all deleted\" will silently misreport","result_ids must be a non-empty array (400 otherwise) of at most 300 ids (422 beyond that)","batch larger sets yourself"],"returns":["id","reason"]},{"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` \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, verified live: 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":"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 edit.","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":"automation_status","type":"string","json_path":"/test_case/automation_status"},{"name":"case_type","type":"integer","json_path":"/test_case/case_type"},{"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"},{"name":"owner","type":"integer","description":"TM user id.","json_path":"/test_case/owner"},{"name":"preconditions","type":"string","json_path":"/test_case/preconditions"},{"name":"priority","type":"integer","json_path":"/test_case/priority"},{"name":"status","type":"integer","json_path":"/test_case/status"},{"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"}],"intent":"Bulk Update Test Cases","guidance":["bare values, REPLACE-only (async above 100)","Update multiple test cases within a project","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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"}],"query":[{"name":"include_subfolders_tc","type":"boolean"},{"name":"status_id","type":"integer"}],"body":[{"name":"attachments","type":"array","example":[]},{"name":"de_selected_ids","type":"array","example":[]},{"name":"description","type":"string","example":"

something

","description":"HTML formatted comment or note"},{"name":"folder_id","type":"integer","example":21},{"name":"issues","type":"array","example":[]},{"name":"mapping_ids","type":"array","example":[999,991,1024,1019]},{"name":"select_all","type":"boolean","example":false},{"name":"status_id","type":"integer","example":21},{"name":"test_run_ids","type":"array","example":[1001,1002,1003],"description":"Array of BTCER IDs to apply the bulk edit to (passed when automation = true)"},{"name":"test_case_counts","type":"array","example":[1,3,2],"description":"Array of test case counts corresponding to each BTCER ID in test_run_ids (passed when automation = true)"}],"intent":"Bulk edit test cases result in test run","guidance":["Update multiple test cases in a specific test run within a project, allowing for bulk operations such as changing status, adding attachments, and more"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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, verified live: an unrecognised key here is SILENTLY DROPPED, so a typo widens the ta"},{"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},{"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},{"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`.\n\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},{"name":"value","type":"string"}],"json_path":"/test_case/priority"},{"name":"status","type":"string","fields":[{"name":"operation","type":"string","required":true},{"name":"value","type":"string"}],"json_path":"/test_case/status"},{"name":"case_type","type":"string","fields":[{"name":"operation","type":"string","required":true},{"name":"value","type":"string"}],"json_path":"/test_case/case_type"},{"name":"automation_state","type":"string","fields":[{"name":"operation","type":"string","required":true},{"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},{"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},{"name":"value","type":"string"}],"json_path":"/test_case/owner"},{"name":"preconditions","type":"string","description":"Text value.","fields":[{"name":"operation","type":"string","required":true},{"name":"value","type":"string"}],"json_path":"/test_case/preconditions"},{"name":"review_status","type":"string","fields":[{"name":"operation","type":"string","required":true},{"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},{"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":["PREFERRED bulk write","per-field { value, operation }","Correct for one case too (pass a single id)","Update many test cases in ONE call","Every field is an object of the form {\"value\": \u2026, \"operation\": \u2026}","so this is the only write path that can ADD or REMOVE from a collection without replacing it"],"returns":["async","unique_id"],"paginated":true},{"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` \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, verified live: 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":"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 move.","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":"Move every case in scope, minus `de_selected_ids`.","json_path":"/test_case/select_all"},{"name":"destination_folder_id","type":"integer","description":"Target folder id. Falls back to `folder_id` when omitted.","json_path":"/test_case/destination_folder_id"},{"name":"folder_id","type":"integer","description":"Legacy alias for `destination_folder_id`, used only when that is absent.","json_path":"/test_case/folder_id"},{"name":"destination_project_id","type":"integer","description":"Set only for a cross-project move. Shared cases cannot be moved across projects (422).","json_path":"/test_case/destination_project_id"}],"intent":"Bulk Move Test Cases","guidance":["Move multiple test cases from one folder to another within a project","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"paginated":true},{"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":"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, verified live: 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":"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 Retrieve Test Cases","guidance":["Retrieve multiple test cases within a project based on specified criteria","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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,"description":"Plan INTEGER ids."}],"intent":"Un-archive test plans in bulk (undo bulk-archive)","guidance":["Restore previously archived plans"],"returns":["async","unique_id"]},{"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":"Clone an exploratory session (async when it has many logs).","guidance":["clone a session (async when it has many logs)"]},{"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","description":"Name for the clone. Defaults to a server-generated copy name.","json_path":"/test_plan/name"},{"name":"copy_all_sub_plans","type":"boolean","json_path":"/test_plan/copy_all_sub_plans"},{"name":"sub_plan_ids","type":"array","description":"Clone only these sub-plans. Ignored when copy_all_sub_plans is true.","json_path":"/test_plan/sub_plan_ids"}],"intent":"Clone a test plan, optionally with its sub-plans","guidance":["copy_all_sub_plans or sub_plan_ids to bring its sub-plans along","Copy an existing plan","copy_all_sub_plans: true clones every sub-plan","alternatively pass sub_plan_ids to clone a specific subset","Omit both to clone just the plan itself"]},{"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"}],"body":[{"name":"copy_tc_assignee","type":"boolean","description":"Copy test case assignees from the original run"},{"name":"copy_issues","type":"boolean","description":"Copy issues linked to the original run"},{"name":"copy_tags","type":"boolean","description":"Copy tags from the original run"},{"name":"copy_all_cases","type":"boolean","description":"Include all test cases in the cloned run"},{"name":"status","type":"array","example":["passed","failed"],"description":"Status strings to filter test cases to copy"},{"name":"status_id","type":"array","example":[1,2],"description":"Status IDs to filter test cases to copy"},{"name":"name","type":"string","example":"Cloned Test Run - 2025","description":"Name for the new cloned test run"}],"intent":"Clone a test run","guidance":["Create a new test run by cloning an existing one, with options to copy test case assignees, issues, tags, and filter cases by status"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"Close an exploratory session","guidance":["Transitions an active exploratory session to the closed state"]},{"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"}],"intent":"Close a test run","guidance":["Close a specific test run within a project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"ID of folder to copy"},{"name":"destination_folder_id","type":"integer","required":true,"example":3,"description":"ID of destination parent folder"},{"name":"destination_project_id","type":"integer","required":true,"example":10000,"description":"Destination project ID (can be same or different)"},{"name":"async","type":"boolean","example":true,"description":"Whether to process asynchronously via background job"},{"name":"copy_test_cases","type":"boolean","example":true,"description":"Whether to copy test cases within folder"}],"intent":"Copy a folder to another location","guidance":["Copies a folder (and optionally its contents) to a destination folder within the same or different project","Supports both synchronous and asynchronous operations via the async flag","Async Processing: When async: true, the operation is queued as a Sidekiq background job and returns immediately with a unique tracking ID"],"returns":["async","folder_id","unique_id"]},{"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","example":false,"description":"Select every case in the project.","json_path":"/selection/select_all"},{"name":"unique_test_case_count","type":"integer","example":2,"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.","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`."},{"name":"configurations","type":"array","required":true,"example":[],"description":"Configuration ids. Required \u2014 pass an empty array if there are none."},{"name":"trtc_configuration_mappings","type":"array","description":"Per-configuration case mappings. An ARRAY of objects \u2014 the server rejects an object here.","fields":[{"name":"configuration_id","type":"integer"},{"name":"select_all","type":"boolean"},{"name":"linked_test_cases","type":"array"},{"name":"unlinked_test_cases","type":"array"}]}],"intent":"Count the test cases a selection resolves to (incl. configurations/datasets).","guidance":["count the cases a selection resolves to (incl"],"shape":"discovered"},{"method":"GET","path":"/api/v1/projects/{project_id}/test-plans/{test_plan_id}/test-runs/count","mode":"read","entity":"test_plan","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"test_plan_id","type":"integer","required":true}],"intent":"Count the test runs linked to a test plan","guidance":["how many runs a plan groups","cheaper and safer than paging the run list","Returns just the count of runs linked to the plan","Prefer this over paging the run list when the user only asked \"how many\"","list reads truncate around 30 KB and will make you under-count"],"shape":"discovered"},{"method":"POST","path":"/api/v1/projects/{project_id}/folder/{folder_id}/bulk-test-cases","mode":"write","entity":"folder","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"folder_id","type":"integer","required":true}],"body":[{"name":"test_cases","type":"array","required":true,"fields":[{"name":"name","type":"string","required":true},{"name":"test_case_folder_id","type":"string","required":true},{"name":"attachments","type":"array"},{"name":"automation_state","type":"integer"},{"name":"automation_status","type":"string"},{"name":"case_type","type":"integer"},{"name":"custom_fields","type":"object"},{"name":"description","type":"string"},{"name":"estimate","type":"string"},{"name":"estimated_duration_seconds","type":"integer"},{"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"},{"name":"reviewers","type":"array"},{"name":"send_for_review","type":"boolean"},{"name":"shared_precondition_id","type":"integer"},{"name":"status","type":"integer"},{"name":"tags","type":"array"},{"name":"template","type":"string"},{"name":"template_id","type":"integer"},{"name":"template_step_type","type":"string"},{"name":"test_case_dataset","type":"string"},{"name":"test_case_steps","type":"array"}]},{"name":"unique_id","type":"string","description":"Client-supplied idempotency key. When present and the group has the async bulk-create feature flag enabled, the request is processed asynchronously and acknowledged with 202; completion is delivered over the common WebSocket channel keyed by this id."}],"intent":"Create test cases in bulk for a folder (\u226410) \u2014 UNRELIABLE STATUS, see description.","guidance":["Creates several test cases in one request","more returns 400 \"More than permitted test cases sent\"","A 500 here does NOT mean nothing happened","The action is wrapped in a catch-all rescue that renders 500 {\"success\": false, \"message\": \"Failed to create test cases.\"} for any exception, including ones raised AFTER the cases were already inserted","Same status, same message, opposite outcomes","So on a 500: never retry blind"],"returns":["request_trace_id"]},{"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 new configuration for a project","guidance":["create a configuration ({ name })","Create a new configuration in a specific project"],"returns":["id","name","created_at","group_id","is_suggested","record_status"]},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"body":[{"name":"option_value","type":"string","required":true,"description":"The option label."},{"name":"is_default","type":"boolean","required":true,"description":"REQUIRED \u2014 a missing value is a 400. Must be 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 parent option this hangs under."}],"intent":"Add one option to a dataset \u2014 how you add a dropdown value.","guidance":["ADD one dropdown option","option_value + is_default both required","Adds a single option","Both option_value and is_default are required","a missing is_default is 400 \"Invalid Params\"","For dropdown, at most one option may be the default"]},{"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}],"body":[{"name":"linked_projects","type":"array","description":"Project INTEGER ids this dataset applies to. This is what makes the field visible in a project \u2014 a dataset with no linked projects leaves the field invisible everywhere. NOTE the spelling differs from the project-mappings endpoint, which uses `link_projects` / `unlink_projects`."},{"name":"link_to_future_projects","type":"boolean","description":"Auto-link projects created later."},{"name":"linked_to_all_projects","type":"boolean","description":"Apply to every project in the workspace."},{"name":"options","type":"array","description":"The allowed values, for dropdown-style fields.","fields":[{"name":"option_value","type":"string","required":true},{"name":"is_default","type":"boolean","required":true},{"name":"parent_option_id","type":"integer"}]}],"intent":"Add a dataset (an option set + its project links) to an existing field.","guidance":["add another option set + project scope to an existing field","A dataset is how a field carries options and project scope","A field can hold more than one, letting different projects see different option sets for the same field","the key is linked_projects, and options is accepted at this level (it is a dataset body, not a field body)","for other types a dataset with linked_projects and no options is what scopes the field to projects"]},{"method":"POST","path":"/api/v1/admin-v2/settings/custom-fields","mode":"write","entity":"custom_field","body":[{"name":"field_name","type":"string","required":true},{"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 (e.g. `field_dropdown`) are a different API's and are rejected. Silently immutable after creation: an update that changes it returns 200 and changes nothing."},{"name":"entity_type","type":"string","values":["TestCase","TestResult","TestPlan","ExploratorySession"],"description":"Which entity the field hangs off. One field belongs to exactly one."},{"name":"is_required","type":"boolean","required":true,"description":"REQUIRED, and must be a literal boolean \u2014 omitting it is a 400."},{"name":"datasets","type":"array","required":true,"description":"REQUIRED for every field type, dropdown or not \u2014 omitting it is a 400. Carries the options and the project scope. Send one entry with `linked_projects` set.","fields":[{"name":"linked_projects","type":"array"},{"name":"link_to_future_projects","type":"boolean"},{"name":"linked_to_all_projects","type":"boolean"},{"name":"options","type":"array"}]},{"name":"place_holder_text","type":"string"},{"name":"default_value","type":"string","description":"Only honoured when `field_type` is `boolean`."},{"name":"levels","type":"array","description":"REQUIRED when field_type is `nested_dropdown`, minimum 2 entries, each {name, level_type}."}],"intent":"Create a custom field DEFINITION (workspace-admin action).","guidance":["options AND project scope both go in datasets[]","Creates the field definition","a new attribute available on the chosen entity type","configurable per workspace","so don't predict access","A caller without it gets 403"]},{"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,"description":"Name of the dataset.","json_path":"/dataset/name"},{"name":"variables","type":"array","required":true,"description":"List of dataset variables/columns (max 40).","fields":[{"name":"name","type":"string","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/variables"},{"name":"rows","type":"array","required":true,"description":"List of dataset rows (max 100).","fields":[{"name":"row_number","type":"integer","required":true},{"name":"row_data","type":"array","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/rows"}],"intent":"Create a new dataset.","guidance":["create a dataset with variables + rows","Create a new dataset with variables and rows for a project"],"returns":["name","created_at","rows_count","uuid"]},{"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.","json_path":"/exploratory_session/title"},{"name":"description","type":"string","example":"Explore edge cases in the checkout flow.","description":"Optional description.","json_path":"/exploratory_session/description"},{"name":"charter","type":"string","example":"Focus on payment error handling","description":"Testing charter describing the scope and goals.","json_path":"/exploratory_session/charter"},{"name":"timebox_duration","type":"integer","example":60,"description":"Planned duration in minutes.","json_path":"/exploratory_session/timebox_duration"},{"name":"owner","type":"integer","example":42,"description":"TCM user ID of the session owner / assignee.","json_path":"/exploratory_session/owner"},{"name":"assignee","type":"integer","example":42,"description":"Alias for `owner`. TCM user ID of the assignee.","json_path":"/exploratory_session/assignee"},{"name":"folder_id","type":"integer","example":456,"description":"ID of the folder to place this session in.","json_path":"/exploratory_session/folder_id"},{"name":"tags","type":"array","example":["regression","mobile"],"description":"Tags for the session.","json_path":"/exploratory_session/tags"},{"name":"configurations","type":"array","example":[1,3],"description":"Configuration IDs to associate with this session.","json_path":"/exploratory_session/configurations"},{"name":"attachments","type":"array","example":[101,102],"description":"Blob / media attachment IDs.","json_path":"/exploratory_session/attachments"},{"name":"issues","type":"array","description":"Issues to link to this session.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/exploratory_session/issues"}],"intent":"Create an exploratory session","guidance":["body wrapped in exploratory_session (title required)","Creates a new exploratory session within the specified project"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"]},{"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 button \u2014 spinner appeared but order was not created.

","description":"Rich-text HTML content of the log entry.","json_path":"/session_log/content"},{"name":"status","type":"string","values":["pass","fail","bug","retest","block","note"],"example":"fail","description":"Test result status for this log step.","json_path":"/session_log/status"},{"name":"elapsed_time","type":"integer","example":120,"description":"Time elapsed for this step in seconds.","json_path":"/session_log/elapsed_time"},{"name":"defects","type":"array","description":"Defects to link to this log entry.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/session_log/defects"}],"intent":"Create a session log entry","guidance":["add a log entry (rich-text HTML, sanitised on write)","Adds a new log entry to the specified exploratory session","Rich-text HTML content is sanitised before storage"],"returns":["id","status","content","created_at","elapsed_time","session_id","updated_at"]},{"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,"description":"Name of the filter"},{"name":"entity","type":"string","required":true,"values":["testcase","testplan","testrun","exploratory_session","test_plan_test_case"],"description":"Entity type the filter applies to. The server's validator accepts five values (the\nlast two were missing here); an unlisted value returns 400 \"Invalid JSON data\"."},{"name":"is_project_level","type":"boolean","description":"Whether this filter is available at project level"},{"name":"filters","type":"object","required":true,"description":"Filter criteria object containing the actual filter conditions"}],"intent":"Create a new filter","guidance":["save a filter view ({ name, entity, filters, is_project_level })","Create a new saved filter for test cases, test plans, or test runs in a project"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"]},{"method":"POST","path":"/api/v1/projects","mode":"write","entity":"project","body":[{"name":"name","type":"string","required":true,"json_path":"/project/name"},{"name":"description","type":"string","json_path":"/project/description"}],"intent":"Create a new project.","guidance":["Pinned to human approval","Create a new project with name and description"],"returns":["name","description"]},{"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},{"name":"report_type","type":"string","required":true,"values":["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","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_statu"},{"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 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`"},{"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;\n`custom_report` is a user-configured one. For a Requirement Traceability\nreport, `system_generated` makes the server auto-populate\n`report_filters.requirements` with every requirement in the project."},{"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"},{"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":"Integer run ids \u2014 pairs with report_timeframe=specific_test_runs.","json_path":"/report_filters/test_runs"},{"name":"test_plans","type":"array","description":"Integer plan ids \u2014 pairs with report_timeframe=specific_test_plans.","json_path":"/report_filters/test_plans"},{"name":"test_cases","type":"string","description":"Case selection \u2014 pairs with report_timeframe=specific_test_cases. FOLDER-KEYED\n(see SelectionData), never a flat id list: the server resolves the selection\nthrough its folder map, so any other shape yields zero cases and saves an\nUNSCOPED, project-wide report at 200.\n\nSend all three keys. Folder ids are STRING keys; test-case ids are INTEGERS\n(the numeric `id`, not the TC-NNN identifier) \u2014 take bo","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":"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.","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 } }.","json_path":"/report_filters/test_plan_selection"},{"name":"builds","type":"array","json_path":"/report_filters/builds"},{"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\n`Test Run Summary`, `Test Run Detailed Report` and `Test Case Activity` \u2014 on\nthe other six report types the server never reads it and drops it silently.\nPersisted (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`."},{"name":"users","type":"array","json_path":"/mail_to/users"},{"name":"external_mails","type":"array","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\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"},{"name":"time","type":"string","example":"08:00","description":"24-hour HH:MM, UTC.","json_path":"/frequency_details/time"},{"name":"day","type":"string","example":"monday","description":"Required for weekly (lowercase day name) and monthly (integer 1-28).\nOmit for daily.","json_path":"/frequency_details/day"}],"intent":"Create a new scheduled report for a project","guidance":["Create a report configuration under the given project"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"]},{"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","description":"Name of the root folder"},{"name":"notes","type":"string","example":"This folder contains all regression test cases","description":"Optional notes or description for the folder"}],"intent":"Create a root-level folder for test cases in a project","guidance":["Create a new root-level folder for organizing test cases within a project"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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},{"name":"folder_id","type":"integer","description":"ID of the shared-field folder to place the shared step in. Null or omitted means the root (Unassigned)."},{"name":"shared_step_details","type":"array","required":true,"fields":[{"name":"order","type":"integer","required":true},{"name":"step","type":"string"},{"name":"result","type":"string"},{"name":"test_data","type":"string"}]}],"intent":"Create a new shared step in a project","guidance":["Create a new shared step within a project"],"returns":["id","title","step_count","test_case_count"]},{"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},{"name":"test_run_id","type":"string","required":true},{"name":"test_case_id","type":"integer","required":true},{"name":"step_id","type":"integer","required":true}],"body":[{"name":"status_id","type":"integer","json_path":"/test_run_step_result/status_id"},{"name":"description","type":"string","json_path":"/test_run_step_result/description"}],"intent":"Record a per-step result for a case in a run (v1).","guidance":["record a per-step outcome for a step-templated case ({ status_id, description })"]},{"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,"description":"Name of the new folder.","json_path":"/folder/name"},{"name":"notes","type":"string","description":"Description of the new folder.","json_path":"/folder/notes"}],"intent":"create subfolder inside a specific folder","guidance":["Create a new subfolder within a specific folder in a project"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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 the FIRST test case in a project that has no folders (needs create_at_root).","guidance":["Narrow-purpose route: the first case in a project that has zero folders","Verified live on a folderless project: 200, and the folder appears","The route and the flag always travel together","The product's UI derives both from one boolean","no folders exist AND the user picked no folder","A correct refusal, not something to retry: list the folders and pick one"],"returns":["result","step"]},{"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,"fields":[{"name":"name","type":"string","required":true},{"name":"test_case_folder_id","type":"string","required":true},{"name":"attachments","type":"array"},{"name":"automation_state","type":"integer"},{"name":"automation_status","type":"string"},{"name":"case_type","type":"integer"},{"name":"custom_fields","type":"object"},{"name":"description","type":"string"},{"name":"estimate","type":"string"},{"name":"estimated_duration_seconds","type":"integer"},{"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"},{"name":"reviewers","type":"array"},{"name":"send_for_review","type":"boolean"},{"name":"shared_precondition_id","type":"integer"},{"name":"status","type":"integer"},{"name":"tags","type":"array"},{"name":"template","type":"string"},{"name":"template_id","type":"integer"},{"name":"template_step_type","type":"string"},{"name":"test_case_dataset","type":"string"},{"name":"test_case_steps","type":"array"}]},{"name":"create_at_root","type":"boolean"}],"intent":"Create test cases for a folder in a project.","guidance":["create a case in a folder","body wrapped in test_case, name required","Create one or more test cases within a specific folder in a project","If create_at_root is true, the test case will be created at the root of the project"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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","json_path":"/test_plan/name"},{"name":"description","type":"string","json_path":"/test_plan/description"},{"name":"start_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/start_date"},{"name":"end_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/end_date"},{"name":"plan_status","type":"string","description":"Plan status. The list filter accepts new / started / completed.","json_path":"/test_plan/plan_status"},{"name":"owner","type":"integer","description":"Owner user id \u2014 resolve via the project users read first.","json_path":"/test_plan/owner"},{"name":"parent_plan_id","type":"integer","description":"Parent plan's INTEGER id. Set it to create (or re-parent into) a SUB-plan \u2014 the\nresult carries an STP-NNN identifier. Null/omitted means a top-level plan.","json_path":"/test_plan/parent_plan_id"},{"name":"tags","type":"array","json_path":"/test_plan/tags"},{"name":"reviewers","type":"array","description":"Reviewer user ids.","json_path":"/test_plan/reviewers"},{"name":"attachments","type":"array","description":"Attachment ids already uploaded via the attachments surface.","json_path":"/test_plan/attachments"},{"name":"custom_fields","type":"object","json_path":"/test_plan/custom_fields"},{"name":"issues","type":"array","description":"Linked tracker issues. Structured \u2014 NOT a flat array of keys.","fields":[{"name":"issue_id","type":"string"},{"name":"issue_type","type":"string"},{"name":"metadata","type":"object"}],"json_path":"/test_plan/issues"},{"name":"test_runs","type":"string","description":"The plan's linked test runs. Accepts EITHER an array of test-run integer ids, OR a\nbulk-selection object for \"every run matching this filter\". On update this REPLACES\nthe existing link set rather than appending.","json_path":"/test_plan/test_runs"}],"intent":"Create a test plan \u2014 or a SUB-plan, by setting parent_plan_id","guidance":["body wrapped in test_plan","Create a test plan in the project","Everything goes under the test_plan key","test_runs accepts two shapes","Both are honoured server-side","an unknown option is rejected"]},{"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"},{"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":["log a NEW result for a case in a run","Create a new test result for a specific test case within a test run in a project"],"returns":["id","status","backtrace","configuration_id","created_at","created_by_imported","custom_fields","description","error_description","expanded","failure_type","finished_at"]},{"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],"description":"Explicit case ids to pin into the run. Top level, NOT inside `test_run`. Use this or `selection`."},{"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":["Create a new test run for a specific project, which can be used to execute test cases"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"dataset_id","type":"integer","required":true},{"name":"option_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"Delete one option (POST to /delete) \u2014 DESTRUCTIVE, drops values using it.","guidance":["destroys the values that used it","Removing an option deletes the stored values that used it, across every project linked to the dataset","that preserves the values"]},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"Delete a dataset (POST to /delete) \u2014 DESTRUCTIVE, drops its options and values.","guidance":["drops its options and the values the linked projects stored","Removes the option set and unlinks its projects, destroying the values those projects had stored for this field","Name the affected projects before calling"]},{"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}],"intent":"Delete a field definition (POST to /delete) \u2014 DESTRUCTIVE, cascades to stored values.","guidance":["destroys every stored value in every linked project","Name the projects first","Removes the definition and every value stored for it, in every project it is linked to","There is no bin and no undo","Before calling: read the field, say how many projects it is linked to, and name them","If the user only wants it hidden rather than destroyed, that is a different ask"]},{"method":"DELETE","path":"/api/v1/projects/{project_id}/datasets/{uuid}","mode":"destructive","entity":"dataset","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"uuid","type":"string","required":true}],"intent":"Delete a dataset.","guidance":["say it isn't available rather than calling it, and don't substitute by parsing the file yourself","A 422 here means NOT-ENTITLED, not a bad payload","say so and stop, don't retry with a different body","BINDING shape is DIFFERENT from the dataset shape, and this is the usual failure","the dataset's uuid repeated on every column it owns","The binding object has NO top-level uuid or name (both stripped by strong params)"]},{"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":"Delete an exploratory session","guidance":["Deletes an exploratory session"]},{"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":"Delete a session log entry","guidance":["Permanently deletes a log entry from the specified exploratory session"]},{"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 filter","guidance":["entity = testcase | testplan | testrun | exploratory_session","filter_id is an integer","reuse a saved view's filters as the q for a later search or bulk action"]},{"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":"Delete a folder and its contents","guidance":["Deletes a folder by its ID and optionally returns the parent folder's path hierarchy"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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"}],"intent":"Delete a report (this removes the report itself, not just its schedule)","guidance":["returns the remaining reports","merely stopping a report from recurring","The response is the list of remaining reports","There is no bin and no undo","so confirm the report's name with the user first"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"]},{"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","guidance":["affects every test case embedding it","Deletes a shared step from a project"]},{"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":"Delete an attachment from a test case in a folder in a project","guidance":["read the file from that url"],"returns":["id","byte_size","content_type","filename"]},{"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":"Delete a test plan (or sub-plan) \u2014 no bin, no undo","guidance":["The server performs no plan-type check","so this deletes a sub-plan by its own integer id exactly the same way","Deleting a parent plan is destructive to its children"]},{"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 test result","guidance":["Prefer PATCHing the latest result to correct a mistake","Deleting a result removes an execution record","the case's latest status is recomputed from what remains"]},{"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"}],"intent":"delete test run","guidance":["The discovered ids must be CARRIED INTO the create body","discovery alone puts nothing in the run","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","It is validated before the selection","so a bare 400 'invalid data' means a bad test_run field"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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},{"name":"duplicate_id","type":"integer","required":true}],"body":[{"name":"discard_reason","type":"string","description":"Short reason code / label for why this isn't a duplicate."},{"name":"user_feedback","type":"string","description":"Free-text feedback from the user."}],"intent":"Discard a duplicate suggestion \u2014 dismisses it WITHOUT touching test cases","guidance":["DISCARD a suggestion","dismisses it WITHOUT touching either test case","The safe default when the user says it's not a duplicate or is unsure","Mark a recommendation as discarded","This is the safe way to dismiss a suggestion: it changes only the recommendation's status to discarded and leaves both test cases exactly as they are","discard_reason and user_feedback are optional but feed the dedupe model"]},{"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":"Desired export format. Note this is a STRING here, while the same-named field\non `send_email_now` is an ARRAY."},{"name":"required","type":"object","description":"Optional column selection, honoured only for `Test Run Detailed Report`\n(ignored for every other type). Shape:\n`{ \"\": { \"fields\": [\"id\",\"title\",...] } }`. Omit to get the report's\ndefault columns."}],"intent":"Initiate a download of a report in a specified file format","guidance":["Request generation and download channel for the specified report"],"returns":["channel"]},{"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"},{"name":"custom_fields","type":"object","example":{"123":"option_value","456":["multi","select"]},"json_path":"/test_case/custom_fields"},{"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"},{"name":"preconditions","type":"string","json_path":"/test_case/preconditions"},{"name":"priority","type":"integer","example":483,"json_path":"/test_case/priority"},{"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\".","json_path":"/test_case/reviewers"},{"name":"status","type":"integer","example":496,"json_path":"/test_case/status"},{"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":"string","description":"Dataset binding for a data-driven case. On this path it is applied only when `is_dataset_modified` is true.","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"}],"intent":"Partially edit a test case (v2)","guidance":["PREFERRED partial update","send ONLY changed fields","Partially update the details of a test case within a specific folder in a project","it is the authoritative list","send test_case_steps instead - estimate is accepted and then dropped","estimated_duration_seconds is the one that persists"],"returns":["result","step"]},{"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"},{"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"},{"name":"custom_fields","type":"object","json_path":"/test_case/custom_fields"},{"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"},{"name":"name","type":"string","json_path":"/test_case/name"},{"name":"owner","type":"integer","json_path":"/test_case/owner"},{"name":"preconditions","type":"string","json_path":"/test_case/preconditions"},{"name":"priority","type":"integer","json_path":"/test_case/priority"},{"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"},{"name":"tags","type":"array","json_path":"/test_case/tags"},{"name":"template","type":"string","json_path":"/test_case/template"},{"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":"string","description":"Dataset binding for a data-driven case. On create the mappings are always regenerated from this, so `is_dataset_modified` is ignored here.","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","json_path":"/test_case/test_case_steps"}],"intent":"Edit a test case","guidance":["Update the details of a test case within a specific folder in a project","Omitted fields are left untouched EXCEPT template, template_id and review_status, which are reset to defaults"],"returns":["result","step"]},{"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"}],"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],"description":"Explicit case ids to pin into the run. Top level, NOT inside `test_run`. Use this or `selection`."},{"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":"Edit Test Run","guidance":["Update the details of a specific test run within a project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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","pdf","excel"],"example":"csv","description":"Export file format"},{"name":"start_date","type":"string","example":"2025-01-01","json_path":"/filters/start_date"},{"name":"end_date","type":"string","example":"2025-12-31","json_path":"/filters/end_date"},{"name":"status","type":"array","example":["passed","failed"],"json_path":"/filters/status"}],"intent":"Export dashboard analytics data","guidance":["Initiates an asynchronous export of dashboard analytics data in the specified format","The export is processed via background job and returns an export ID for tracking","Async Processing: Data export runs via Sidekiq ExportDashboardWorker::FileExportWorker","Supported Formats: CSV, PDF, Excel (based on type parameter)"],"returns":["export_id"]},{"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":"Get active test run result statistics","guidance":["Retrieve statistics of active test runs for a specific project"],"shape":"discovered"},{"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":"Get archived test cases.","guidance":["Retrieve a list of archived test cases in a specific project"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"Count archived test plans","guidance":["count archived plans","Returns {success, count}","the number of archived plans in the project"],"shape":"discovered"},{"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 automation stats analytics","guidance":["Retrieve automation statistics for a specific project"],"returns":["automated_coverage","automated_test_cases","empty_data","manual_test_cases","total_test_cases"]},{"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":"Get all the closed test runs for a project","guidance":["Retrieve a list of all closed test runs for a specific project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"Get closed test run analytics","guidance":["Retrieve statistics of closed test runs for a specific project"],"returns":["month"]},{"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":"Get closed test runs split analytics","guidance":["Retrieve split statistics of closed test runs for a specific project"],"returns":["name"]},{"method":"GET","path":"/api/v1/projects/{project_id}/configurations","mode":"read","entity":"configuration","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Get configurations for a project","guidance":["Retrieve a list of configurations for a specific project"],"returns":["id","name","created_at","group_id","is_suggested","record_status"]},{"method":"GET","path":"/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets/{dataset_id}","mode":"read","entity":"custom_field","path_params":[{"name":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"Read one dataset \u2014 its project links and option set.","guidance":["read one dataset's project links and options"],"shape":"discovered"},{"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}],"intent":"Read one custom field definition, with its datasets and project links.","guidance":["read one definition + its datasets and linked projects","do this BEFORE any update, which is a full replace","Options are NOT inline","Datasets come back WITHOUT their options inline","Read this before any update","so you need the current values to avoid clearing them"],"shape":"discovered"},{"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},{"name":"entity_type","type":"string","required":true,"values":["test-case","test-result","test-plan"]},{"name":"field_id","type":"string","required":true}],"query":[{"name":"q","type":"string"}],"intent":"Get the allowed values/options of one custom field.","guidance":["If nothing matches, the field doesn't exist in that workspace","say so rather than guessing a field id","Multi-dropdowns need OPTION IDS, not labels","That legacy family writes a table local to the Rails app that is DEPRECATED and that nothing reads","It is blocked at the gate","testhub owns definitions"],"shape":"discovered","paginated":true},{"method":"GET","path":"/api/v1/projects/{project_id}/datasets/{uuid}","mode":"read","entity":"dataset","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"uuid","type":"string","required":true}],"intent":"Get a specific dataset.","guidance":["read one dataset by UUID","Retrieve a specific dataset by its UUID including all variables and rows"],"returns":["name","created_at","rows_count","uuid"]},{"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},{"name":"uuid","type":"string","required":true}],"intent":"Get test cases linked to a dataset","guidance":["list the test cases bound to this dataset (paged p)","Returns the test cases linked to a dataset, identified by its UUID"],"shape":"discovered","paginated":true},{"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"}],"intent":"Get dataset variables/columns.","guidance":["BINDING shape is DIFFERENT from the dataset shape, and this is the usual failure","the dataset's uuid repeated on every column it owns","The binding object has NO top-level uuid or name (both stripped by strong params)","so do NOT copy the dataset's read shape into it","Never report a dataset as linked from the 200 alone","A dataset is addressed by a UUID (the dataset path param), NOT an integer"],"returns":["name","uuid"],"paginated":true},{"method":"GET","path":"/api/v1/projects/{project_id}/ai-duplicates/status","mode":"read","entity":"duplicate","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Dedupe job status \u2014 use this to tell \"feature off\" from \"no duplicates\"","guidance":["ALWAYS call this when the list is empty","Status of the project's most recent dedupe run"],"returns":["status"]},{"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},{"name":"duplicate_id","type":"integer","required":true}],"intent":"Read the pre-merge snapshot of a MERGED duplicate's source test case","guidance":["after a merge, read the pre-merge snapshot of the case that was folded away","After a merge, the source case no longer exists independently","this returns the snapshot captured at merge time","the only way to show the user what was folded away","Only works for status merged (404 otherwise), and the snapshot may legitimately be absent, which also surfaces as a 404 with error: \"Source test case snapshot not available\"","Treat that as \"not retained\", not as an error to retry"],"shape":"discovered"},{"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},{"name":"duplicate_id","type":"integer","required":true}],"intent":"Read one suggested duplicate, with its test cases resolved","guidance":["read one suggestion with its test cases resolved","do this BEFORE merging so the user can see what would be destroyed","Only status=suggested is readable","Full detail for one recommendation, including the actual test cases it links","so you can show the user what would be merged","Only status suggested is readable here"],"shape":"discovered"},{"method":"GET","path":"/api/v1/projects/{project_id}/{entity}/filter-details","mode":"read","entity":"project","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"entity","type":"string","required":true,"values":["test-cases","trtc","test-runs"],"example":"test-cases"}],"query":[{"name":"q[query]","type":"string","example":"login functionality"},{"name":"q[folder_ids]","type":"string","example":"3306878,3669741,5422801,5422802"},{"name":"q[status]","type":"string","example":"9319,9321"},{"name":"q[priority]","type":"string","example":"550376,9261"},{"name":"q[automation_state]","type":"string","example":"18172471,18172473"},{"name":"q[case_type]","type":"string","example":"9379,9375"},{"name":"q[owner]","type":"string","example":"user123,user456"},{"name":"q[assignee]","type":"string","example":"user789,user101"},{"name":"q[tags]","type":"string","example":"regression,smoke"},{"name":"q[created_at]","type":"string","example":"2024-01-01..2024-12-31"},{"name":"q[updated_at]","type":"string","example":"2024-01-01..2024-12-31"}],"intent":"Get filter details for entity","guidance":["Retrieve filter details for a specific entity type (test-cases, trtc, or test-runs) within a project"],"returns":["id","colour","entity_type","field_name","field_value","internal_name"]},{"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":"Get an exploratory session","guidance":["read one session by INTEGER id","Returns a single exploratory session by its ID"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"]},{"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"]},{"name":"start_date","type":"string"},{"name":"end_date","type":"string"},{"name":"session_ids","type":"string"}],"intent":"Exploratory Session Summary \u2014 live view, no saved report needed","guidance":["exploratory-session summary WITHOUT creating a report","Computes an exploratory-session summary on demand","there is no Schedule row behind it","Use it to answer \"summarise our exploratory sessions\" without creating a report first","Returns session counts, bugs found, top testers (resolved to user objects) and the session list"],"shape":"discovered"},{"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":"Get filter details","guidance":["Retrieve details of a specific saved filter by its ID","It does NOT validate or strip invalid custom-field references","a filter saved with a non-existent custom-field id is returned unchanged","A missing or deleted filter comes back as 400 {\"success\": false, \"message\": \"Filter not found\"}, not 404"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"]},{"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":"Get summary of entities to be deleted when removing a folder","guidance":["PREVIEW what deleting a folder will remove before calling rm","Provides a summary of all entities (test cases, recordings, sub-folders) that would be deleted if the folder is removed"],"returns":["active_test_case_count","archived_test_case_count","sub_folders","test_recordings_count"]},{"method":"GET","path":"/api/v1/projects/{project_id}/repository/mapping","mode":"read","entity":"folder","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Get the project's folder tree (v1).","guidance":["the whole project folder TREE in one call (structure array)"],"shape":"discovered"},{"method":"GET","path":"/api/v1/group/users-v2","mode":"read","entity":"user","query":[{"name":"q","type":"string"}],"intent":"Get group users V2 with pagination","guidance":["Retrieve a paginated list of users in the group with search and filtering capabilities, WITHOUT project scoping","used by the Global Search filter dropdowns","Search by user IDs (comma-separated) or by a name string"],"returns":["id","browserstack_user_id","email","full_name","group_id","onboarded","reports_and_notification_enabled"]},{"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":"Get issues count analytics","guidance":["Retrieve the count of issues for a specific project"],"returns":["label"]},{"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","guidance":["read ONE project by its INTEGER id","so for an integer id this is the right read","Do NOT translate first just to fetch the project","It serves two jobs at once: (1) READ","returns the project's full detail (name, description, permissions, \u2026)","In other words it is NOT translation-only"],"returns":["id","identifier","name","created_at","description","jira_mapped","starred","test_cases_count","test_plans_count","test_runs_count","thProjectId","user_role"]},{"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}],"intent":"Get project-specific custom fields V2 for test cases","guidance":["START HERE for CUSTOM fields","Does NOT expose system-field option ids","Retrieve custom fields, default fields, and system fields for test cases in a specific project (V2 format)"],"returns":["id","default_value","entity_type","field_name","field_type","group_id","is_bulk_editable","is_filterable","is_required","linked_projects_count","place_holder_text","system_name"]},{"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"]}],"intent":"Get project settings","guidance":["Returns an array of enabled setting keys"],"returns":["in_review_count","test_plan_in_review_count"]},{"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"}],"intent":"Get project users V2 with pagination","guidance":["Retrieve paginated list of users in the group with search and filtering capabilities","Search by user IDs (comma-separated) or by name string"],"returns":["id","browserstack_user_id","email","full_name","group_id","onboarded","reports_and_notification_enabled"]},{"method":"GET","path":"/api/v1/projects/basic","mode":"read","entity":"project","query":[{"name":"q[query]","type":"string","example":"nishchay3"},{"name":"q[identifier]","type":"string","example":"PR-60863"}],"intent":"Get paginated projects (lean payload) \u2014 use this to list or count projects","guidance":["LIST or COUNT projects","so you can reach a true total without truncating","Those two are the only recognised keys"],"returns":["id","name","description","import_id","normalisedName","starred","thProjectId"],"paginated":true},{"method":"GET","path":"/api/v1/projects/minify","mode":"read","entity":"project","intent":"Get all projects (minified dropdown) \u2014 has NO name search","guidance":["never use it to resolve a named project or to count them","Minified list of active projects, for dropdown-style selection","It accepts no q in any form","the backend call has no query option","so it can only page through everything","Do NOT use it to resolve a project the user named"],"returns":["id","name","description","import_id","normalisedName","starred","thProjectId"],"paginated":true},{"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},{"name":"attachment_id","type":"integer","required":true}],"intent":"Get download URL for a report attachment","guidance":["Retrieve a secure URL to download a specific report attachment"],"returns":["url"]},{"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"},{"name":"report_data_only","type":"boolean"},{"name":"today","type":"string","example":"2025-05-13"}],"intent":"Retrieve a scheduled report's config and data \u2014 the general report read","guidance":["works for every report type","Full configuration plus computed data for a report of ANY type","request the section and look at the result","which is a real finding","Pass sections to compute the sections you need","several in ONE call, cheaper than one request per section"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"],"paginated":true},{"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"}],"query":[{"name":"widget_type","type":"string"},{"name":"segment","type":"string"}],"intent":"Fetch one report widget/section \u2014 works for EVERY report type.","guidance":["Section names are validated per type","*_drilldown sections return paginated ROWS, not aggregates","Returns a single section's data for any report type","This is the general section read","so when you need SEVERAL sections, prefer that form with a comma-separated sections list and take them in one call rather than one request per section","Most sections return the computed aggregate for a widget"],"shape":"discovered","paginated":true},{"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","requirement_traceability_report"]}],"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":"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`"},{"name":"sections","type":"string","example":"test_run_data,test_run_status"},{"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"]},{"name":"segment","type":"string"}],"intent":"Get summary statistics for a scheduled report \u2014 run and traceability types ONLY","guidance":["400s on every other type","so not for Test Case Activity or Test Plan Summary","Summary statistics for a scheduled report, for THREE report types only","including every other type in ReportType","This is not the general report-read","optionally with sections"],"shape":"discovered","paginated":true},{"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 of the folder to search for"},{"name":"include_folder_hierarchy","type":"boolean","description":"Whether to include folder hierarchy in the response"}],"intent":"Get all folders and subfolders present in the projects.","guidance":["list root folders (paged with p","Returns all folders and subfolders in a project if it exists in TestHub"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"],"paginated":true},{"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":"Get already selected testcases for a tracebility report","guidance":["Retrieve the already selected testcases for a tracebility report"],"returns":["select_all","unique_test_case_count"]},{"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":"paginated","type":"boolean"}],"intent":"Get shared steps","guidance":["read one shared step with its ordered step details","Retrieves a list of shared steps for a project"],"returns":["id","title"],"paginated":true},{"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":"q[query]","type":"string"},{"name":"pre_fetch","type":"boolean"}],"intent":"Get all shared steps for a project","guidance":["Returns a list of all shared steps within a project"],"returns":["id","title","step_count","test_case_count"],"paginated":true},{"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"}],"intent":"THE source for a system field's option ids (priority/status/case_type/automation_state).","guidance":["a single attachment-URL field is enough to push the response past the ~30 KB cap","Supports q to search the option list and p to page it, which matters","a workspace can accumulate dozens of options on a single field"],"shape":"discovered","paginated":true},{"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":"Get all attachments for a test case in a folder in a project","guidance":["list a case's attachments","Retrieve all attachments associated with a specific test case within a folder","CURRENTLY RETURNING 500","Verified live against five different real test cases, with both the case's true folder_id and a mismatched one","a reproducible server error, not a bad request","Do NOT retry it and do not report it to the user as their mistake"],"returns":["id","byte_size","content_type","filename"]},{"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":"Get a test case by INTEGER id (v1, folder-scoped) \u2014 full detail + its TC-NNN identifier","guidance":["READ one case by INTEGER id (folder-scoped)","Read a test case by its INTEGER id","so for an integer id this v1 read is the ONLY way to fetch the case","Two jobs at once: (1) READ","NOT translation-only","Don't fall back to listing the folder to find the case: if you have the integer id, read it here directly"],"returns":["id","identifier","title"]},{"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":"Get test case count trend analytics","guidance":["Retrieve the trend of test case counts for a specific project"],"returns":["field"]},{"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","example":"folder_path"}],"intent":"Get Test Case Details","guidance":["full detail (steps, custom fields, issues) before editing","read this, improve, then write back"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"Get test case histories","guidance":["list all version records for a test case","Retrieve all history records for a specific test case"],"returns":["id","comment","created_at","entity_id","entity_type","group_id","project_id","source","user_id","version_id","version_name"]},{"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}],"intent":"Get test case history diff","guidance":["compare two versions","versions[] with exactly two ids (PAID plan)","Retrieve the diff of a specific history record for a test case"],"returns":["id","order","result","shared_step_id","step"]},{"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":"Get a specific test case history","guidance":["Retrieve details of a specific test case history by its ID"],"returns":["id","comment","created_at","entity_id","entity_type","group_id","project_id","source","user_id","version_id","version_name"]},{"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":"Get tags and metadata for a test case","guidance":["read a case's current tags","Retrieve the tags and metadata associated with a specific test case in a project"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"Get test case type split analytics","guidance":["Retrieve the split of test case types for a specific project"],"returns":["name","field","value","y"]},{"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"}],"query":[{"name":"required[fields]","type":"string","values":["count"]}],"intent":"Get paginated test cases for a test run","guidance":["page the cases in a run (each row is the case you log results against)","Retrieve a paginated list of test cases for a specific test run in a project"],"shape":"discovered"},{"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":"required[fields]","type":"string","example":"owner,priority"},{"name":"required[custom_fields]","type":"string","example":"121,119"},{"name":"all_folders","type":"string","values":["true"],"example":"true"}],"intent":"List a project's test cases \u2014 REQUIRES all_folders=true to be project-wide.","guidance":["Without all_folders=true this returns only ONE folder's cases","the project's first root folder","So a project-wide question answered from a bare call silently under-counts by whatever sits in every other folder, and nothing in the response says so","all_folders is compared as the STRING 'true' (all_folders = params['all_folders'] == 'true')","A JSON boolean true, 1, TRUE or any other value all fail that check and silently fall back to the single-folder scope"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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":"Get a test plan by INTEGER id (v1) \u2014 full detail + its TP-NNN identifier","guidance":["READ one plan by INTEGER id","Read a test plan by its INTEGER id (project integer id in the path)","Don't translate first just to read the plan","this returns its full detail directly","Two jobs at once: (1) READ","the plan's full detail (under test_plan"],"returns":["identifier","name"]},{"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"},{"name":"test_run_id","type":"integer"},{"name":"today","type":"string"}],"intent":"Execution-trend chart data for ONE plan or ONE run (XOR)","guidance":["execution-trend chart data for ONE plan or ONE run","pass test_plan_id XOR test_run_id (both or neither is a 400)","Time-series execution trend backing the Insights tab chart","Scope it with exactly one of test_plan_id or test_run_id","Passing both returns 400 \"Provide either test_plan_id or test_run_id, not both\"","passing neither returns 400 \"test_plan_id or test_run_id is required\""],"shape":"discovered"},{"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 a test plan","guidance":["chart data for the exploratory sessions linked to a plan","Aggregated chart data for exploratory sessions linked to the plan","the other half of the Insights tab","Like execution-trend, this is chart data only","insights widgets themselves are not a CRUD resource here"],"shape":"discovered"},{"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":"include_sub_test_plan_runs","type":"boolean"},{"name":"compact","type":"boolean"},{"name":"is_archived","type":"boolean"}],"intent":"List the test runs linked to a test plan","guidance":["list the runs a plan groups","Paginated list of the runs a plan groups","that field replaces the link set rather than appending to it","include_sub_test_plan_runs=true also returns runs belonging to the plan's sub-plans"],"shape":"discovered","paginated":true},{"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}],"intent":"Get paginated custom fields for test results","guidance":["Retrieve paginated list of custom fields configured for test results in a project"],"returns":["id","default_value","entity_type","field_name","field_type","field_user_name","is_bulk_editable","is_filterable","is_required","optional","placeholder"],"paginated":true},{"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"},{"name":"test_case_id","type":"integer","required":true}],"intent":"Get test results for a test case in a test run","guidance":["read a case's results within a run","Retrieve a list of test results for a specific test case within a test run in a project"],"returns":["id","status","backtrace","configuration_id","created_at","created_by_imported","custom_fields","description","error_description","expanded","failure_type","finished_at"]},{"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":["READ a run by INTEGER id","Read a test run by its INTEGER id (with the project's integer id in the path)","Don't translate first just to read the run","this returns its full detail directly","Two jobs at once: (1) READ","NOT translation-only"],"returns":["id","identifier","name","uuid"]},{"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},{"name":"test_run_id","type":"string","required":true}],"intent":"Get a run with its per-case rows (v1).","guidance":["the run with its per-case rows (did it pass?)"],"shape":"discovered"},{"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"}],"intent":"Get custom field values for test results in test runs","guidance":["Retrieve default field values and optionally custom fields for test results (TRTC - Test Run Test Case)"],"returns":["id","applies_to_all_projects","default_value","entity_type","field_name","field_type","is_required","link_to_future_projects","place_holder_text"]},{"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.","json_path":"/selection/select_all"},{"name":"unique_test_case_count","type":"integer","example":2,"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.","json_path":"/selection/folders"},{"name":"shared_selection","type":"object"}],"intent":"get selected test runs of a project","guidance":["Retrieve selected test runs for a specific project based on the provided selection criteria"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"ignore_run_type","type":"boolean"},{"name":"include_test_plan","type":"boolean"}],"intent":"Get all the test runs for a project","guidance":["list the project's (open) runs, paged with p","Retrieve a list of all test runs for a specific project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"Import a CSV dataset \u2014 NOT SUPPORTED in this profile","guidance":["NOT SUPPORTED in this profile","say CSV import isn't available","CSV import is not supported here","If asked to import a dataset from CSV, say it isn't available and point to the Test Management UI"]},{"method":"GET","path":"/api/v1/activities","mode":"read","entity":"activity","query":[{"name":"project_id","type":"integer"},{"name":"cursor","type":"string"},{"name":"limit","type":"integer"},{"name":"actor_id","type":"integer"},{"name":"starred_only","type":"boolean"},{"name":"entity_type","type":"array"}],"intent":"Read the activity feed (audit trail) \u2014 CURSOR paged, 15-day window","guidance":["WHO changed WHAT recently","group-wide audit trail","Group-wide audit trail of who changed what","Read-only: activity events cannot be created, edited, or deleted","Two things differ from every other list in this profile: - Cursor pagination, not page numbers","so you cannot jump to a page or read a total"],"returns":["no_starred_projects"]},{"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}],"intent":"List archived test plans","guidance":["where a 'missing' plan usually is, and the source of ids for bulk-retrieve","Paginated list of the project's archived plans","so if a plan the user names seems missing, look here before concluding it was deleted"],"shape":"discovered","paginated":true},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"List a dataset's options with their ids.","guidance":["list a dataset's options WITH their ids","the ids a filter or a test-case write needs"],"shape":"discovered"},{"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"},{"name":"q[uuid]","type":"string"}],"intent":"List datasets for a project.","guidance":["list datasets (paged p","Retrieve a paginated list of datasets for a project with optional filtering by name or UUID"],"returns":["name","created_at","rows_count","test_cases_count","updated_at","uuid"],"paginated":true},{"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":"entity_type","type":"string"},{"name":"entity_id","type":"integer"},{"name":"duplicates","type":"string"}],"intent":"List AI-suggested duplicate test cases \u2014 an empty list may mean the feature is OFF","guidance":["LIST the duplicate suggestions awaiting review","An EMPTY list may mean the feature is off","List the AI dedupe recommendations awaiting review in a project","Only duplicates with status suggested are returned","once merged, archived, or discarded they leave this list","An empty result is ambiguous"],"shape":"discovered","paginated":true},{"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}],"intent":"List defects for an exploratory session","guidance":["List defaults to active sessions","the parent project takes the INTEGER project id (v1)","defects are the issues linked to the session"],"returns":["issue_id","issue_type"],"paginated":true},{"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}],"intent":"List logs for an exploratory session","guidance":["page the session's log entries","Returns a paginated list of log entries for the specified exploratory session"],"returns":["id","status","content","created_at","elapsed_time","session_id","updated_at"],"paginated":true},{"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":"q[session_state]","type":"string","values":["active","closed"],"example":"active"},{"name":"q[assignee]","type":"integer","example":42},{"name":"q[owner]","type":"integer","example":42},{"name":"q[created_by]","type":"integer","example":10},{"name":"q[created_at_from]","type":"string","example":"2026-01-01"},{"name":"q[created_at_to]","type":"string","example":"2026-01-31"},{"name":"q[tags][]","type":"array","example":["regression","payment"]},{"name":"q[configuration_ids][]","type":"array","example":[1,2]},{"name":"q[search]","type":"string","example":"checkout"},{"name":"q[status]","type":"string","example":"pass"}],"intent":"List exploratory sessions for a project","guidance":["list sessions (defaults to active","Returns a paginated list of exploratory sessions"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"],"paginated":true},{"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","type":"string","required":true,"values":["testcase","testplan","testrun","exploratory_session","test_plan_test_case"]}],"intent":"List filters","guidance":["list saved filter views (optional entity = testcase|testplan|testrun|exploratory_session)","Retrieve a paginated list of saved filters for a project, optionally filtered by entity type"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"],"paginated":true},{"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":"List subfolders and folder metadata for a specific folder in a project","guidance":["one folder's detail + its direct subfolders + ancestors","Retrieve a list of subfolders and their metadata for a specific folder in a project"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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":"required[fields]","type":"string","example":"owner,priority"},{"name":"required[custom_fields]","type":"string","example":"121,119"}],"intent":"List the test cases in one folder","guidance":["Page through the cases directly inside a folder, by the folder's INTEGER id","Use this when the user has already named or selected a folder","Rows are verbose and list reads truncate around 30 KB"],"shape":"discovered","paginated":true},{"method":"GET","path":"/api/v1/projects","mode":"read","entity":"project","query":[{"name":"q","type":"string","example":"nishchay3"},{"name":"sort_by","type":"string"},{"name":"starred","type":"boolean"},{"name":"shared","type":"boolean"},{"name":"is_hidden_projects","type":"boolean","example":true}],"intent":"List projects for a group.","guidance":["Paginated list of the group's projects","query and page are not read by the server","so use this to FIND a project, not to enumerate them"],"returns":["id","identifier","name","created_at","description","import_id","project_creator","test_cases_count","test_runs_count"],"paginated":true},{"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"},{"name":"q[scheduled]","type":"string","values":["true","false"]}],"intent":"List all the scheduled reports","guidance":["list the project's report schedules (paged p","Retrieve a paginated list of schedules"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"],"paginated":true},{"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":"q","type":"string"}],"intent":"List test-case tags (paginated).","guidance":["list a project's test-case tags (paged with p, optional q)"],"shape":"discovered","paginated":true},{"method":"GET","path":"/api/v1/admin-v2/settings/templates","mode":"read","entity":"","query":[{"name":"entity_type","type":"string","values":["TestCase","TestResult","TestPlan","ExploratorySession"]},{"name":"project","type":"integer"},{"name":"name","type":"string"}],"intent":"List the workspace's test-case templates \u2014 THE source for `template_id`.","guidance":["Ids are per-workspace","so one carried over from another workspace (or from an example) produces the generic 400 \"The API request is invalid or improperly formed\" on a test-case create, with nothing naming the offending field","Call this when the user asked for a named template, or to confirm a template id before reusing one","One call therefore yields both fields","NOTE THE CASING: entity_type here is CamelCase (TestCase), unlike the hyphenated test-case used in path segments elsewhere","Passing test-case returns an empty list with a 200"],"shape":"discovered","paginated":true},{"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":"include_sub_plans","type":"boolean"},{"name":"status","type":"string","values":["new","started","completed"]},{"name":"sort_by","type":"string"},{"name":"minify","type":"boolean"}],"intent":"List test plans in a project (optionally including sub-plans)","guidance":["include_sub_plans=true to see sub-plans too","Paginated list of the project's test plans","This is how you find a plan's integer id before reading, updating, or deleting it","so never try to find a plan through search","Without it you see only top-level plans and a plan's children are invisible"],"shape":"discovered","paginated":true},{"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"]},{"name":"field_source","type":"string","values":["system","custom","all"]},{"name":"field_type","type":"string"},{"name":"q","type":"string"}],"intent":"THE canonical workspace field list (system + custom) \u2014 start here for definitions.","guidance":["workspace-wide list of DEFINITIONS across all projects","'does a field called X exist anywhere?'","The authoritative list (testhub-backed)","This is the authoritative definition list: it is backed by testhub, which owns custom-field definitions","That legacy collection reads a DEPRECATED table local to the Rails app that nothing else consults","its contents are unrelated to the real fields"],"shape":"discovered","paginated":true},{"method":"POST","path":"/api/v1/projects/{project_id}/tags/merge","mode":"write","entity":"tag","path_params":[{"name":"project_id","type":"integer","required":true}],"body":[{"name":"source_id","type":"integer","required":true},{"name":"target_id","type":"integer","required":true},{"name":"entity_type","type":"string","required":true,"values":["test_case","test_run","test_plan","shared_step"]}],"intent":"Merge one tag into another (v1). Admin-gated (tags_management).","guidance":["merge one tag into another ({ source_id, target_id, entity_type })"]},{"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","required":true,"example":123,"description":"ID of the new parent folder (internal APIs)"},{"name":"new_base_project_id","type":"integer","example":456,"description":"Optional destination project ID for cross-project moves"},{"name":"user_action","type":"string","example":"move_folder"}],"intent":"Move a test case folder to a different parent folder","guidance":["move a folder ({ new_base_folder_id, new_base_project_id })","cross-project moves run async","Move a test case folder to a new parent folder within the same project","This operation updates the folder's hierarchy without changing its contents"],"returns":["async","unique_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","required":true,"description":"Name of the new folder.","json_path":"/folder/name"},{"name":"notes","type":"string","description":"Description of the new folder.","json_path":"/folder/notes"}],"intent":"rename a folder in a project","guidance":["Rename an existing folder within a specific project"],"returns":["request_trace_id"]},{"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],"description":"List of folder IDs to reorder"},{"name":"prev","type":"integer","example":101,"description":"ID of the folder that will precede the moved folder"},{"name":"next","type":"integer","example":103,"description":"ID of the folder that will follow the moved folder"}],"intent":"Reorder folders in a project","guidance":["Reorder folders within a project by specifying the current order and optionally the previous and next folder IDs"],"returns":["request_trace_id"]},{"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":"ID of the test case that will be above the moved ones.","json_path":"/re_order/top_test_case"},{"name":"bottom_test_case","type":"string","description":"ID of the test case that will be below the moved ones.","json_path":"/re_order/bottom_test_case"},{"name":"test_cases","type":"array","required":true,"description":"Array of test case IDs to be moved.","json_path":"/re_order/test_cases"}],"intent":"Reorder test cases within a folder of a project","guidance":["reorder cases within a folder","Reorders test cases in a folder by placing them between top_test_case and bottom_test_case"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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. Passed in the BODY, not the path."},{"name":"merge_action","type":"string","required":true,"description":"`merge` folds source into target; any other value archives."},{"name":"source_testcase_id","type":"integer","description":"Required for merge \u2014 the case that will cease to exist independently."},{"name":"target_testcase_id","type":"integer","description":"Required for merge \u2014 the case that survives."}],"intent":"Resolve a duplicate by MERGING or ARCHIVING \u2014 destroys or hides a test case","guidance":["RESOLVE a suggestion","merge_action=merge folds source_testcase_id into target_testcase_id (DESTRUCTIVE to a test case), anything else archives","duplicate_id goes in the BODY","Act on one dedupe recommendation","merge_action selects what happens: - \"merge\"","folds source_testcase_id into target_testcase_id"]},{"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":"Restore a specific history entry","guidance":["roll the case back to this version (PAID plan","Restore a specific history entry by its ID"]},{"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":"duplicates","type":"string","description":"Duplicate-type filter."},{"name":"owner","type":"string","description":"Owner tab \u2014 scopes to the caller's own duplicates vs all."}],"intent":"Filtered search over suggested duplicates","guidance":["filtered search over suggestions (owner tab, duplicate type, test-case attributes)","Subject to the identical flag caveat: an empty list may mean the feature is off rather than that there are no duplicates"],"shape":"discovered","paginated":true},{"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"}],"query":[{"name":"q","type":"string"}],"intent":"Search group tags for an entity type","guidance":["search tags across the whole group (entity_type = test-case|test-run|test-plan)","Retrieve a paginated list of tags for the given entity type across the group (no project scoping)","used by the Global Search filter dropdowns"],"returns":["name","created_at","usage_count"],"paginated":true},{"method":"GET","path":"/api/v1/projects/{project_id}/{entity}/search","mode":"read","entity":"project","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"entity","type":"string","required":true,"values":["test-cases","test-runs","test-plans","reports"]}],"query":[{"name":"q[query]","type":"string","example":"tc"},{"name":"q[folder_id]","type":"integer","example":29529465},{"name":"use_bstack_id","type":"string","values":["0","1"]},{"name":"include_test_plan","type":"boolean"}],"intent":"Search for entities within a project","guidance":["Returns paginated results matching the search criteria"],"shape":"discovered","paginated":true},{"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":"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":["Note: Array values in the q parameter are automatically converted to comma-separated strings for compatibility with existing search logic"],"shape":"discovered","paginated":true},{"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"}],"intent":"Search users with access to a project","guidance":["Retrieves a paginated list of users who have access to the specified project","Returns user details including permissions, feature flags, and product roles"],"returns":["id","browserstack_user_id","customisable_dashboard_accessible","full_name","group_id","has_access","is_build_listing_enabled","is_delighted_visible","is_jira_app_project_panel_visible","is_lcnc_explore_dismissed","is_new_project_settings_visible","is_onboarding_project_header_visible"],"paginated":true},{"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"}],"intent":"Search test case tags","guidance":["Retrieves a paginated list of tags associated with test cases in the project","Returns both simple tag strings and detailed tag objects with metadata"],"returns":["name","created_at","usage_count"],"paginated":true},{"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":"query","type":"string"},{"name":"use_bstack_id","type":"string","values":["0","1"]}],"intent":"Search test cases in a project","guidance":["and see pagination-and-filtering for the key table and each value's source","there is NO top-level values array, and looking for one finds nothing","so it is routinely cut off and appears absent","NEVER probe candidate integers for an id","The four system option fields","a name silently returns 0 because the server matches an id column"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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","example":["qa-leads@company.com"],"description":"Recipient addresses. MUST be an array, even for one address."},{"name":"file_type","type":"array","example":["pdf"],"description":"Formats to attach. MUST be an array. Defaults to [\"pdf\"]."}],"intent":"Email a report immediately (async job).","guidance":["email the report immediately (async job)","Kicks off a background send","a 200 means the job was queued, NOT that mail was delivered","don't report it as sent","Both body fields are ARRAYS and are rejected with 400 \" should be an array\" if sent as scalars","Omitting emails sends to the report's configured recipients"]},{"method":"POST","path":"/api/v1/projects/{project_id}/folder/{folder_id}/undo-rm","mode":"write","entity":"folder","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"folder_id","type":"integer","required":true}],"intent":"Undo folder deletion","guidance":["Restores a previously deleted folder and returns its metadata along with path hierarchy"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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":"dataset_id","type":"integer","required":true},{"name":"option_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"body":[{"name":"option_value","type":"string","required":true,"description":"The option label."},{"name":"is_default","type":"boolean","required":true,"description":"REQUIRED \u2014 a missing value is a 400. Must be 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 parent option this hangs under."}],"intent":"Rename an option or change its default (POST to /update).","guidance":["NON-destructive, stored values follow the option id","Both option_value and is_default are required","a missing is_default is a 400","Renaming an option keeps the stored values pointing at it (the option id is unchanged)","so this is the NON-destructive way to fix a label"]},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"body":[{"name":"link_projects","type":"array","description":"Project INTEGER ids to ADD. Note the spelling \u2014 not `linked_projects`."},{"name":"unlink_projects","type":"array","description":"Project INTEGER ids to REMOVE. Destroys their stored values."},{"name":"link_to_future_projects","type":"boolean"},{"name":"select_all","type":"boolean","description":"Apply to every project; may switch the call to async."}],"intent":"Link or unlink projects for a dataset \u2014 how you change which projects see a field.","guidance":["CHANGE WHICH PROJECTS","Unlinking destroys that project's values","The keys here are link_projects and unlink_projects","Send the wrong spelling and it is silently dropped: 200, nothing changed","This is the single easiest mistake on this surface","These are DELTAS, not the complete new list: send only the projects to add and the projects to remove"]},{"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,"description":"Display label. Editable. REQUIRED even when unchanged."},{"name":"field_type","type":"string","required":true,"description":"REQUIRED for validation, but any change is silently ignored."},{"name":"is_required","type":"boolean","required":true,"description":"REQUIRED \u2014 omitting it is a 400."},{"name":"entity_type","type":"string"},{"name":"place_holder_text","type":"string"},{"name":"default_value","type":"string","description":"Only applied when `field_type` is `boolean`."}],"intent":"Update a field definition's label, requiredness or placeholder (POST to /update).","guidance":["Full replace: field_name + field_type + is_required all required","field_type changes are silently ignored","field_name, field_type and is_required must all be present","Omitting is_required is 400 \"Invalid Params\"","omitting field_name is a 500","field_name IS editable here"]},{"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","description":"Updated name of the dataset.","json_path":"/dataset/name"},{"name":"variables","type":"array","description":"Updated list of dataset variables/columns (max 40).","fields":[{"name":"name","type":"string","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/variables"},{"name":"rows","type":"array","description":"Updated list of dataset rows (max 100).","fields":[{"name":"row_number","type":"integer","required":true},{"name":"row_data","type":"array","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/rows"}],"intent":"Update a dataset.","guidance":["Update an existing dataset by its UUID with new variables and rows"],"returns":["name","created_at","rows_count","uuid"]},{"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 for the session.","json_path":"/exploratory_session/title"},{"name":"description","type":"string","description":"Updated description.","json_path":"/exploratory_session/description"},{"name":"charter","type":"string","description":"Updated testing charter.","json_path":"/exploratory_session/charter"},{"name":"timebox_duration","type":"integer","example":90,"description":"Updated planned duration in minutes.","json_path":"/exploratory_session/timebox_duration"},{"name":"duration","type":"integer","example":45,"description":"Tracked duration in minutes.","json_path":"/exploratory_session/duration"},{"name":"actual_duration","type":"integer","example":50,"description":"Final actual duration in minutes.","json_path":"/exploratory_session/actual_duration"},{"name":"folder_id","type":"integer","example":789,"description":"Move the session to a different folder.","json_path":"/exploratory_session/folder_id"},{"name":"owner","type":"integer","example":55,"description":"TCM user ID of the new owner / assignee.","json_path":"/exploratory_session/owner"},{"name":"assignee","type":"integer","example":55,"description":"Alias for `owner`.","json_path":"/exploratory_session/assignee"},{"name":"tags","type":"array","example":["smoke","payment"],"description":"Replace the session's tags with this list.","json_path":"/exploratory_session/tags"},{"name":"configurations","type":"array","example":[2,4],"description":"Replace the session's configuration IDs.","json_path":"/exploratory_session/configurations"},{"name":"attachments","type":"array","description":"Updated set of blob / media attachment IDs.","json_path":"/exploratory_session/attachments"},{"name":"issues","type":"array","description":"Replace the linked issues for this session.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/exploratory_session/issues"}],"intent":"Update an exploratory session","guidance":["edit a session (title, charter, timebox_duration, duration, owner, tags, ...)","Updates one or more fields of an exploratory session","Time fields (timebox_duration, duration, actual_duration) must be non-negative integers not exceeding 2147483647"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"]},{"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":"Updated rich-text HTML content of the log entry.","json_path":"/session_log/content"},{"name":"status","type":"string","values":["pass","fail","bug","retest","block","note"],"example":"fail","description":"Updated test result status.","json_path":"/session_log/status"},{"name":"elapsed_time","type":"integer","example":180,"description":"Updated elapsed time in seconds.","json_path":"/session_log/elapsed_time"},{"name":"defects","type":"array","description":"Replace the linked defects for this log entry.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/session_log/defects"}],"intent":"Update a session log entry","guidance":["Updates an existing log entry within an exploratory session","Rich-text HTML content is sanitised before storage"],"returns":["id","status","content","created_at","elapsed_time","session_id","updated_at"]},{"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,"description":"Name of the filter"},{"name":"entity","type":"string","required":true,"values":["testcase","testplan","testrun","exploratory_session","test_plan_test_case"],"description":"Entity type the filter applies to. The server's validator accepts five values (the\nlast two were missing here); an unlisted value returns 400 \"Invalid JSON data\"."},{"name":"is_project_level","type":"boolean","description":"Whether this filter is available at project level"},{"name":"filters","type":"object","description":"Filter criteria object containing the actual filter conditions"}],"intent":"Update a filter","guidance":["Update an existing saved filter with new configuration or filter criteria"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"]},{"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"},{"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 these are ids, not names).","json_path":"/test_result/status_id"},{"name":"description","type":"string","description":"Rich text description of the test result.","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's 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":"Update the latest test result","guidance":["update the LATEST result for a case in a run (partial, same body shape)","Update the most recent test result for a specific test case within a test run in a project","Optionally filter by configuration_id or mapping_id"],"returns":["id","name","byte_size","checksum","content_type","download_url","key","product_id","size","url"]},{"method":"POST","path":"/api/v1/projects/{project_id}/settings","mode":"write","entity":"project","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Update project settings","guidance":["Accepts an object with setting keys as properties and boolean values"]},{"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","example":10000},{"name":"report_type","type":"string","required":true,"values":["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","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_statu"},{"name":"title","type":"string","example":"Updated Title"},{"name":"description","type":"string","example":""},{"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 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`"},{"name":"report_creation_mode","type":"string","required":true,"values":["custom_report","system_generated"],"example":"custom_report"},{"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"},{"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":"Integer run ids \u2014 pairs with report_timeframe=specific_test_runs.","json_path":"/report_filters/test_runs"},{"name":"test_plans","type":"array","description":"Integer plan ids \u2014 pairs with report_timeframe=specific_test_plans.","json_path":"/report_filters/test_plans"},{"name":"test_cases","type":"string","description":"Case selection \u2014 pairs with report_timeframe=specific_test_cases. FOLDER-KEYED\n(see SelectionData), never a flat id list: the server resolves the selection\nthrough its folder map, so any other shape yields zero cases and saves an\nUNSCOPED, project-wide report at 200.\n\nSend all three keys. Folder ids are STRING keys; test-case ids are INTEGERS\n(the numeric `id`, not the TC-NNN identifier) \u2014 take bo","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":"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.","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 } }.","json_path":"/report_filters/test_plan_selection"},{"name":"builds","type":"array","json_path":"/report_filters/builds"},{"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\n`Test Run Summary`, `Test Run Detailed Report` and `Test Case Activity` \u2014 on\nthe other six report types the server never reads it and drops it silently.\nPersisted (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`."},{"name":"users","type":"array","json_path":"/mail_to/users"},{"name":"external_mails","type":"array","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\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"},{"name":"time","type":"string","example":"08:00","description":"24-hour HH:MM, UTC.","json_path":"/frequency_details/time"},{"name":"day","type":"string","example":"monday","description":"Required for weekly (lowercase day name) and monthly (integer 1-28).\nOmit for daily.","json_path":"/frequency_details/day"}],"intent":"Update a scheduled report","guidance":["FULL REPLACE, not a delta: omitted fields are nulled and dropping frequency cancels the schedule","Update the configuration of an existing scheduled report for a project"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"]},{"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":"Title of the shared step"},{"name":"folder_id","type":"integer","description":"ID of the shared-field folder to move the shared step into. Null moves it to the root (Unassigned)."},{"name":"shared_step_details","type":"array","required":true,"fields":[{"name":"id","type":"integer"},{"name":"step","type":"string"},{"name":"result","type":"string"},{"name":"order","type":"integer"},{"name":"test_data","type":"string"}]}],"intent":"Update a shared step","guidance":["update a shared step","title and shared_step_details are BOTH required, and the details array REPLACES the existing steps","Updates an existing shared step in a project"],"returns":["id","title"]},{"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"},{"name":"description","type":"string","json_path":"/test_plan/description"},{"name":"start_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/start_date"},{"name":"end_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/end_date"},{"name":"plan_status","type":"string","description":"Plan status. The list filter accepts new / started / completed.","json_path":"/test_plan/plan_status"},{"name":"owner","type":"integer","description":"Owner user id \u2014 resolve via the project users read first.","json_path":"/test_plan/owner"},{"name":"parent_plan_id","type":"integer","description":"Parent plan's INTEGER id. Set it to create (or re-parent into) a SUB-plan \u2014 the\nresult carries an STP-NNN identifier. Null/omitted means a top-level plan.","json_path":"/test_plan/parent_plan_id"},{"name":"tags","type":"array","json_path":"/test_plan/tags"},{"name":"reviewers","type":"array","description":"Reviewer user ids.","json_path":"/test_plan/reviewers"},{"name":"attachments","type":"array","description":"Attachment ids already uploaded via the attachments surface.","json_path":"/test_plan/attachments"},{"name":"custom_fields","type":"object","json_path":"/test_plan/custom_fields"},{"name":"issues","type":"array","description":"Linked tracker issues. Structured \u2014 NOT a flat array of keys.","fields":[{"name":"issue_id","type":"string"},{"name":"issue_type","type":"string"},{"name":"metadata","type":"object"}],"json_path":"/test_plan/issues"},{"name":"test_runs","type":"string","description":"The plan's linked test runs. Accepts EITHER an array of test-run integer ids, OR a\nbulk-selection object for \"every run matching this filter\". On update this REPLACES\nthe existing link set rather than appending.","json_path":"/test_plan/test_runs"}],"intent":"Update a test plan (or sub-plan)","guidance":["Update a plan by its INTEGER id","Takes the same body as create","so it also works on a sub-plan by its own id","clearing it promotes a sub-plan to a top-level plan","do that only when the user actually asked to move it in the hierarchy","Send only the fields you intend to change"]},{"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 AI-generated or AI-processed attachments","guidance":["upload a file as AI CONTEXT (restricted MIME types, smaller size cap) to seed test-case content","Files are stored in S3 with pre-signed URLs","File Storage: Files uploaded to S3 with pre-signed URLs (expires in ~26 minutes)"],"returns":["id","name","content_type","download_url","size","url"]},{"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 generic attachments to a project from rich text editor or other sources","guidance":["UPLOAD file blob(s) (multipart)","Uploads one or more files as generic attachments to a project","Files are stored in S3 and pre-signed URLs are returned for both viewing and downloading"],"returns":["id","name","content_type","download_url","size","url"]},{"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":"Upload one or more attachments to a test case","guidance":["Upload one or more attachments to a specific test case within a folder in a project"],"returns":["id","byte_size","content_type","filename"]},{"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 whose per-test-case / per-run / per-day breakdown is requested."}],"intent":"Fetch the User Workload drill-down for a single user","guidance":["User-Workload per-user execution breakdown","Only valid on a report whose type is User Workload Report","every other type returns 400 \"Drill-down is only available for User Workload Reports\""],"shape":"discovered","paginated":true}],"entities":{"activity":{"entity":"activity","title":"Activity feed (audit trail)","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_v1"]},"attachment":{"entity":"attachment","title":"Attachments","aliases":["attachment","file","attachments","blob"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"attached to"},{"entity":"result","via":"attached to"}],"capabilities":["delete_test_case_attachment","get_test_case_attachments_v1","upload_a_i_attachments","upload_generic_attachments","upload_test_case_attachments_v1"]},"configuration":{"entity":"configuration","title":"Configurations","aliases":["config","configuration","environment","browser","os","device"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_run","via":"case status tracked against"}],"capabilities":["create_configuration_v1","get_configurations_v1"]},"custom_field":{"entity":"custom_field","title":"Custom fields & system fields","aliases":["custom field","custom fields","field","system field"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"extends"},{"entity":"result","via":"extends"}],"capabilities":["create_custom_field_dataset_option_v2","create_custom_field_dataset_v2","create_custom_field_definition_v2","delete_custom_field_dataset_option_v2","delete_custom_field_dataset_v2","delete_custom_field_definition_v2","get_custom_field_dataset_v2","get_custom_field_definition_v2","get_custom_field_values_v1","get_project_id_custom_fields_v2_v1","get_test_results_custom_fields_v1","list_custom_field_dataset_options_v2","list_workspace_fields_v2","update_custom_field_dataset_option_v2","update_custom_field_dataset_project_mapping_v2","update_custom_field_definition_v2"]},"dataset":{"entity":"dataset","title":"Dataset","aliases":["datasets","data-driven-data","dds"],"id_convention":"uuid","parents":["project"],"relations":[{"entity":"test_case","via":"drives"},{"entity":"variable","via":"has columns"}],"capabilities":["create_dataset","delete_dataset","get_dataset","get_dataset_linked_test_cases","get_dataset_variables","import_dataset_c_s_v","list_datasets","update_dataset"]},"duplicate":{"entity":"duplicate","title":"Duplicate recommendation (AI dedupe)","aliases":["duplicate","duplicates","dedupe","deduplication","duplicate recommendation","ai duplicates","redundant test cases"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"links the pair it believes are duplicates"}],"capabilities":["discard_duplicate_v1","get_dedupe_status_v1","get_duplicate_source_test_case_v1","get_duplicate_v1","list_duplicates_v1","resolve_duplicate_v1","search_duplicates_v1"]},"exploratory_session":{"entity":"exploratory_session","title":"Exploratory session","aliases":["session","exploratory","et-session"],"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_v1","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"]},"filter":{"entity":"filter","title":"Saved filter view","aliases":["filter","filters","saved view","saved 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"]},"folder":{"entity":"folder","title":"Folder","aliases":["dir","directory"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"folder","via":"nests under"},{"entity":"test_case","via":"organises"}],"capabilities":["copy_folder","create_bulk_test_cases_v1","create_root_folder_v1","create_sub_folder_v1","delete_folder_and_contents","get_folder_remove_summary","get_folder_tree_v1","get_root_folders_v1","list_folder_contents","move_folder_v1","rename_folder_v1","reorder_folders","undo_remove_folder"]},"project":{"entity":"project","title":"Project","aliases":["pr","workspace"],"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_v1","export_dashboard_analytics","get_active_test_runs_info_v1","get_automation_stats_v1","get_closed_test_runs_info_v1","get_closed_test_runs_split_v1","get_entity_filter_details_v1","get_issues_count_info_v1","get_project_by_integer_id_v1","get_project_settings","get_project_users_v2_v1","get_projects_basic_v1","get_projects_minify_v1","get_test_case_count_trend_v1","get_test_case_type_split_v1","list_projects_v1","search_project_entities","update_project_settings"]},"report":{"entity":"report","title":"Report","aliases":["reports","scheduled-report","schedule","analytics-report"],"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","delete_report_schedule","download_report","get_exploratory_session_summary_v1","get_report_attachment_url","get_report_detail","get_report_section_v1","get_report_summary_by_type","get_selected_report_testcases","list_schedules","send_report_email_now_v1","update_report","user_workload_drilldown"]},"result":{"entity":"result","title":"Result","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_v1","create_step_result_v1","create_test_result_for_test_case","delete_test_result_v1","get_test_results_for_test_case","update_latest_test_result_for_test_case"]},"shared_step":{"entity":"shared_step","title":"Shared step","aliases":["shared step","shared steps","reusable step","step template"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"reused by"}],"capabilities":["create_shared_step_v1","delete_shared_step_v1","get_shared_steps_by_i_d_v1","get_shared_steps_v1","update_shared_step_v1"]},"tag":{"entity":"tag","title":"Tag","aliases":["tags","label","labels"],"id_convention":"name","parents":["project"],"relations":[{"entity":"test_case","via":"labels"},{"entity":"test_run","via":"labels"},{"entity":"test_plan","via":"labels"}],"capabilities":["bulk_edit_test_cases_v2","get_test_case_tags","list_test_case_tags_v1","merge_tags_v1","search_group_tags","search_test_case_tags"]},"test_case":{"entity":"test_case","title":"Test case","aliases":["tc","case","test case"],"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":["bulk_archive_test_cases_by_project","bulk_copy_test_cases","bulk_delete_test_cases","bulk_edit_test_cases","bulk_edit_test_cases_in_test_run","bulk_move_test_cases","bulk_retrieve_test_cases","create_test_case_v1","create_test_cases","edit_test_case_partial_v1","edit_test_case_v1","get_archived_test_cases","get_system_field_values_v1","get_test_case_by_integer_id_v1","get_test_case_detail","get_test_cases_v1","list_folder_test_cases_v1","reorder_test_cases_by_folder_v1","search_project_entities_v2","search_test_cases_by_folder_v1"]},"test_plan":{"entity":"test_plan","title":"Test plan (and sub-plan)","aliases":["tp","plan","milestone","sub test plan","subplan","stp"],"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_v1","bulk_retrieve_test_plans_v1","clone_test_plan_v1","count_test_plan_test_runs_v1","create_test_plan_v1","delete_test_plan_v1","get_archived_test_plan_count_v1","get_test_plan_by_integer_id_v1","get_test_plan_execution_trend_v1","get_test_plan_linked_sessions_chart_data_v1","get_test_plan_test_runs_v1","list_archived_test_plans_v1","list_test_plans_v1","update_test_plan_v1"]},"test_run":{"entity":"test_run","title":"Test run","aliases":["tr","run","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":["assign_test_run_owner","bulk_assign_test_cases_to_test_run","clone_test_run","close_test_run_v1","count_run_selection_v1","create_test_run_v1","delete_v1_test_run","edit_test_run","get_closed_test_runs","get_test_cases_for_v1_test_run","get_test_run_by_integer_id_v1","get_test_run_cases_v1","get_test_runs_form_fields_v1","get_test_runs_selection","get_test_runs_v1"]},"user":{"entity":"user","title":"User","aliases":["users","member","assignee","owner"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"project","via":"member of"},{"entity":"test_run","via":"assigned to"},{"entity":"test_case","via":"owns"}],"capabilities":["get_group_users_v2_v1","search_project_users"]},"version":{"entity":"version","title":"Test case version","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":["get_test_case_histories_v1","get_test_case_history_diff_v1","get_test_case_history_v1","restore_history_v1"]}}}}} \ No newline at end of file +{"schema_version":1,"build_id":"3c872d0b7b-173caps-8bf0e50","harness_commit":"8bf0e508","products":{"tm":{"summary":"BrowserStack Test Management API (v1 \u2014 the SSO/OAuth app API, cookie-authenticated).","capabilities":[{"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"}],"body":[{"name":"owner","type":"integer","description":"The ID of the user to assign as the owner of the test run."}],"intent":"Assign a owner to the test run","guidance":["Assign a user as the owner of a specific test run within a project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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, verified live: 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":"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":["Archive multiple test cases within a project","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"paginated":true},{"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,"description":"Plan INTEGER ids."}],"intent":"Archive test plans in bulk (reversible \u2014 prefer this over delete)","guidance":["REVERSIBLY remove plans from view","Async above the bulk threshold","Soft-archive one or more plans","reach for it whenever the user wants plans \"removed\" or \"cleaned up\" without saying they must be destroyed"],"returns":["async","unique_id"]},{"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"}],"query":[{"name":"include_subfolders_tc","type":"boolean"}],"body":[{"name":"assignee_id","type":"integer","example":2,"description":"ID of the assignee to whom the test cases will be assigned"},{"name":"mapping_ids","type":"array","example":[999,991,1024,1019],"description":"List of test case IDs to be linked"},{"name":"select_all","type":"boolean","example":false,"description":"Flag to select all test cases"},{"name":"de_selected_ids","type":"array","description":"List of test case IDs to be de-selected"},{"name":"folder_id","type":"integer","description":"ID of the folder to which the test cases belong"}],"intent":"Bulk assign test cases to a test run","guidance":["assign specific cases within a run to a user (async above 300)","Assign multiple test cases to a specific test run within a project, allowing for bulk operations on test case assignments"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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` \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, verified live: an unrecognised key here is SILENTLY DROPPED, so a typo widens the ta"},{"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,"json_path":"/test_case/ids"},{"name":"de_selected_ids","type":"array","required":true,"json_path":"/test_case/de_selected_ids"},{"name":"select_all","type":"boolean","required":true,"json_path":"/test_case/select_all"},{"name":"folder_id","type":"string","description":"SOURCE folder that scopes the selection, at top level. OPTIONAL when you pass explicit `ids` (verified live: the copy succeeds without it), but send it whenever `select_all` is true \u2014 it is what bounds the set, and `select_all` with `folder_id` and no `q` copies exactly that folder's cases. Integer and string are both accepted; the declared string matches what the UI sends here, which unlike bulk-"},{"name":"destination_folder_id","type":"integer","required":true,"description":"Target folder for the copies, and the one genuinely required destination field \u2014 omitting it is a bare `400`, verified live. NOTE it is TOP-LEVEL for copy, unlike bulk-move where the destination sits inside `test_case`."},{"name":"destination_project_id","type":"string","description":"Target project id. Needed only for a copy to ANOTHER project. For a copy WITHIN one project it is OPTIONAL \u2014 verified live, omitting it copies correctly into `destination_folder_id`, and integer and string are both accepted. The product does both: its Copy/Move modal sends the source project id, its clone / drag-and-drop path omits it. Send the source id to be explicit or omit it; never invent a v"}],"intent":"Bulk copy test cases","guidance":["copy a selection to a folder (async above 30)","Bulk copy test cases from one folder to another within a project","This operation allows you to copy multiple test cases at once, which can be useful for organizing or duplicating test cases across different folders","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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` \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, verified live: 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":"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 delete.","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":"Delete every case in scope, minus `de_selected_ids`.","json_path":"/test_case/select_all"}],"intent":"Bulk Delete Test Cases from Search","guidance":["All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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."}],"intent":"Delete test results in bulk \u2014 PARTIAL SUCCESS is normal, always read `skipped`","guidance":["PARTIAL SUCCESS is normal, always read the skipped array back","Authorisation is enforced per id (a caller must be the result's author or a project admin)","treating a 200 as \"all deleted\" will silently misreport","result_ids must be a non-empty array (400 otherwise) of at most 300 ids (422 beyond that)","batch larger sets yourself"],"returns":["id","reason"]},{"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` \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, verified live: 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":"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 edit.","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":"automation_status","type":"string","json_path":"/test_case/automation_status"},{"name":"case_type","type":"integer","json_path":"/test_case/case_type"},{"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"},{"name":"owner","type":"integer","description":"TM user id.","json_path":"/test_case/owner"},{"name":"preconditions","type":"string","json_path":"/test_case/preconditions"},{"name":"priority","type":"integer","json_path":"/test_case/priority"},{"name":"status","type":"integer","json_path":"/test_case/status"},{"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"}],"intent":"Bulk Update Test Cases","guidance":["bare values, REPLACE-only (async above 100)","Update multiple test cases within a project","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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"}],"query":[{"name":"include_subfolders_tc","type":"boolean"},{"name":"status_id","type":"integer"}],"body":[{"name":"attachments","type":"array","example":[]},{"name":"de_selected_ids","type":"array","example":[]},{"name":"description","type":"string","example":"

something

","description":"HTML formatted comment or note"},{"name":"folder_id","type":"integer","example":21},{"name":"issues","type":"array","example":[]},{"name":"mapping_ids","type":"array","example":[999,991,1024,1019]},{"name":"select_all","type":"boolean","example":false},{"name":"status_id","type":"integer","example":21},{"name":"test_run_ids","type":"array","example":[1001,1002,1003],"description":"Array of BTCER IDs to apply the bulk edit to (passed when automation = true)"},{"name":"test_case_counts","type":"array","example":[1,3,2],"description":"Array of test case counts corresponding to each BTCER ID in test_run_ids (passed when automation = true)"}],"intent":"Bulk edit test cases result in test run","guidance":["Update multiple test cases in a specific test run within a project, allowing for bulk operations such as changing status, adding attachments, and more"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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, verified live: an unrecognised key here is SILENTLY DROPPED, so a typo widens the ta"},{"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},{"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},{"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`.\n\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},{"name":"value","type":"string"}],"json_path":"/test_case/priority"},{"name":"status","type":"string","fields":[{"name":"operation","type":"string","required":true},{"name":"value","type":"string"}],"json_path":"/test_case/status"},{"name":"case_type","type":"string","fields":[{"name":"operation","type":"string","required":true},{"name":"value","type":"string"}],"json_path":"/test_case/case_type"},{"name":"automation_state","type":"string","fields":[{"name":"operation","type":"string","required":true},{"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},{"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},{"name":"value","type":"string"}],"json_path":"/test_case/owner"},{"name":"preconditions","type":"string","description":"Text value.","fields":[{"name":"operation","type":"string","required":true},{"name":"value","type":"string"}],"json_path":"/test_case/preconditions"},{"name":"review_status","type":"string","fields":[{"name":"operation","type":"string","required":true},{"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},{"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":["PREFERRED bulk write","per-field { value, operation }","Correct for one case too (pass a single id)","Update many test cases in ONE call","Every field is an object of the form {\"value\": \u2026, \"operation\": \u2026}","so this is the only write path that can ADD or REMOVE from a collection without replacing it"],"returns":["async","unique_id"],"paginated":true},{"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` \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, verified live: 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":"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 move.","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":"Move every case in scope, minus `de_selected_ids`.","json_path":"/test_case/select_all"},{"name":"destination_folder_id","type":"integer","description":"Target folder id. Falls back to `folder_id` when omitted.","json_path":"/test_case/destination_folder_id"},{"name":"folder_id","type":"integer","description":"Legacy alias for `destination_folder_id`, used only when that is absent.","json_path":"/test_case/folder_id"},{"name":"destination_project_id","type":"integer","description":"Set only for a cross-project move. Shared cases cannot be moved across projects (422).","json_path":"/test_case/destination_project_id"}],"intent":"Bulk Move Test Cases","guidance":["Move multiple test cases from one folder to another within a project","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"paginated":true},{"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":"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, verified live: 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":"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 Retrieve Test Cases","guidance":["Retrieve multiple test cases within a project based on specified criteria","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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,"description":"Plan INTEGER ids."}],"intent":"Un-archive test plans in bulk (undo bulk-archive)","guidance":["Restore previously archived plans"],"returns":["async","unique_id"]},{"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":"Clone an exploratory session (async when it has many logs).","guidance":["clone a session (async when it has many logs)"]},{"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","description":"Name for the clone. Defaults to a server-generated copy name.","json_path":"/test_plan/name"},{"name":"copy_all_sub_plans","type":"boolean","json_path":"/test_plan/copy_all_sub_plans"},{"name":"sub_plan_ids","type":"array","description":"Clone only these sub-plans. Ignored when copy_all_sub_plans is true.","json_path":"/test_plan/sub_plan_ids"}],"intent":"Clone a test plan, optionally with its sub-plans","guidance":["copy_all_sub_plans or sub_plan_ids to bring its sub-plans along","Copy an existing plan","copy_all_sub_plans: true clones every sub-plan","alternatively pass sub_plan_ids to clone a specific subset","Omit both to clone just the plan itself"]},{"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"}],"body":[{"name":"copy_tc_assignee","type":"boolean","description":"Copy test case assignees from the original run"},{"name":"copy_issues","type":"boolean","description":"Copy issues linked to the original run"},{"name":"copy_tags","type":"boolean","description":"Copy tags from the original run"},{"name":"copy_all_cases","type":"boolean","description":"Include all test cases in the cloned run"},{"name":"status","type":"array","example":["passed","failed"],"description":"Status strings to filter test cases to copy"},{"name":"status_id","type":"array","example":[1,2],"description":"Status IDs to filter test cases to copy"},{"name":"name","type":"string","example":"Cloned Test Run - 2025","description":"Name for the new cloned test run"}],"intent":"Clone a test run","guidance":["Create a new test run by cloning an existing one, with options to copy test case assignees, issues, tags, and filter cases by status"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"Close an exploratory session","guidance":["Transitions an active exploratory session to the closed state"]},{"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"}],"intent":"Close a test run","guidance":["Close a specific test run within a project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"ID of folder to copy"},{"name":"destination_folder_id","type":"integer","required":true,"example":3,"description":"ID of destination parent folder"},{"name":"destination_project_id","type":"integer","required":true,"example":10000,"description":"Destination project ID (can be same or different)"},{"name":"async","type":"boolean","example":true,"description":"Whether to process asynchronously via background job"},{"name":"copy_test_cases","type":"boolean","example":true,"description":"Whether to copy test cases within folder"}],"intent":"Copy a folder to another location","guidance":["Copies a folder (and optionally its contents) to a destination folder within the same or different project","Supports both synchronous and asynchronous operations via the async flag","Async Processing: When async: true, the operation is queued as a Sidekiq background job and returns immediately with a unique tracking ID"],"returns":["async","folder_id","unique_id"]},{"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","example":false,"description":"Select every case in the project.","json_path":"/selection/select_all"},{"name":"unique_test_case_count","type":"integer","example":2,"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.","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`."},{"name":"configurations","type":"array","required":true,"example":[],"description":"Configuration ids. Required \u2014 pass an empty array if there are none."},{"name":"trtc_configuration_mappings","type":"array","description":"Per-configuration case mappings. An ARRAY of objects \u2014 the server rejects an object here.","fields":[{"name":"configuration_id","type":"integer"},{"name":"select_all","type":"boolean"},{"name":"linked_test_cases","type":"array"},{"name":"unlinked_test_cases","type":"array"}]}],"intent":"Count the test cases a selection resolves to (incl. configurations/datasets).","guidance":["count the cases a selection resolves to (incl"],"shape":"discovered"},{"method":"GET","path":"/api/v1/projects/{project_id}/test-plans/{test_plan_id}/test-runs/count","mode":"read","entity":"test_plan","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"test_plan_id","type":"integer","required":true}],"intent":"Count the test runs linked to a test plan","guidance":["how many runs a plan groups","cheaper and safer than paging the run list","Returns just the count of runs linked to the plan","Prefer this over paging the run list when the user only asked \"how many\"","list reads truncate around 30 KB and will make you under-count"],"shape":"discovered"},{"method":"POST","path":"/api/v1/projects/{project_id}/folder/{folder_id}/bulk-test-cases","mode":"write","entity":"folder","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"folder_id","type":"integer","required":true}],"body":[{"name":"test_cases","type":"array","required":true,"fields":[{"name":"name","type":"string","required":true},{"name":"test_case_folder_id","type":"string","required":true},{"name":"attachments","type":"array"},{"name":"automation_state","type":"integer"},{"name":"automation_status","type":"string"},{"name":"case_type","type":"integer"},{"name":"custom_fields","type":"object"},{"name":"description","type":"string"},{"name":"estimate","type":"string"},{"name":"estimated_duration_seconds","type":"integer"},{"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"},{"name":"reviewers","type":"array"},{"name":"send_for_review","type":"boolean"},{"name":"shared_precondition_id","type":"integer"},{"name":"status","type":"integer"},{"name":"tags","type":"array"},{"name":"template","type":"string"},{"name":"template_id","type":"integer"},{"name":"template_step_type","type":"string"},{"name":"test_case_dataset","type":"string"},{"name":"test_case_steps","type":"array"}]},{"name":"unique_id","type":"string","description":"Client-supplied idempotency key. When present and the group has the async bulk-create feature flag enabled, the request is processed asynchronously and acknowledged with 202; completion is delivered over the common WebSocket channel keyed by this id."}],"intent":"Create test cases in bulk for a folder (\u226410) \u2014 UNRELIABLE STATUS, see description.","guidance":["Creates several test cases in one request","more returns 400 \"More than permitted test cases sent\"","A 500 here does NOT mean nothing happened","The action is wrapped in a catch-all rescue that renders 500 {\"success\": false, \"message\": \"Failed to create test cases.\"} for any exception, including ones raised AFTER the cases were already inserted","Same status, same message, opposite outcomes","So on a 500: never retry blind"],"returns":["request_trace_id"]},{"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 new configuration for a project","guidance":["create a configuration ({ name })","Create a new configuration in a specific project"],"returns":["id","name","created_at","group_id","is_suggested","record_status"]},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"body":[{"name":"option_value","type":"string","required":true,"description":"The option label."},{"name":"is_default","type":"boolean","required":true,"description":"REQUIRED \u2014 a missing value is a 400. Must be 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 parent option this hangs under."}],"intent":"Add one option to a dataset \u2014 how you add a dropdown value.","guidance":["ADD one dropdown option","option_value + is_default both required","Adds a single option","Both option_value and is_default are required","a missing is_default is 400 \"Invalid Params\"","For dropdown, at most one option may be the default"]},{"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}],"body":[{"name":"linked_projects","type":"array","description":"Project INTEGER ids this dataset applies to. This is what makes the field visible in a project \u2014 a dataset with no linked projects leaves the field invisible everywhere. NOTE the spelling differs from the project-mappings endpoint, which uses `link_projects` / `unlink_projects`."},{"name":"link_to_future_projects","type":"boolean","description":"Auto-link projects created later."},{"name":"linked_to_all_projects","type":"boolean","description":"Apply to every project in the workspace."},{"name":"options","type":"array","description":"The allowed values, for dropdown-style fields.","fields":[{"name":"option_value","type":"string","required":true},{"name":"is_default","type":"boolean","required":true},{"name":"parent_option_id","type":"integer"}]}],"intent":"Add a dataset (an option set + its project links) to an existing field.","guidance":["add another option set + project scope to an existing field","A dataset is how a field carries options and project scope","A field can hold more than one, letting different projects see different option sets for the same field","the key is linked_projects, and options is accepted at this level (it is a dataset body, not a field body)","for other types a dataset with linked_projects and no options is what scopes the field to projects"]},{"method":"POST","path":"/api/v1/admin-v2/settings/custom-fields","mode":"write","entity":"custom_field","body":[{"name":"field_name","type":"string","required":true},{"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 (e.g. `field_dropdown`) are a different API's and are rejected. Silently immutable after creation: an update that changes it returns 200 and changes nothing."},{"name":"entity_type","type":"string","values":["TestCase","TestResult","TestPlan","ExploratorySession"],"description":"Which entity the field hangs off. One field belongs to exactly one."},{"name":"is_required","type":"boolean","required":true,"description":"REQUIRED, and must be a literal boolean \u2014 omitting it is a 400."},{"name":"datasets","type":"array","required":true,"description":"REQUIRED for every field type, dropdown or not \u2014 omitting it is a 400. Carries the options and the project scope. Send one entry with `linked_projects` set.","fields":[{"name":"linked_projects","type":"array"},{"name":"link_to_future_projects","type":"boolean"},{"name":"linked_to_all_projects","type":"boolean"},{"name":"options","type":"array"}]},{"name":"place_holder_text","type":"string"},{"name":"default_value","type":"string","description":"Only honoured when `field_type` is `boolean`."},{"name":"levels","type":"array","description":"REQUIRED when field_type is `nested_dropdown`, minimum 2 entries, each {name, level_type}."}],"intent":"Create a custom field DEFINITION (workspace-admin action).","guidance":["options AND project scope both go in datasets[]","Creates the field definition","a new attribute available on the chosen entity type","configurable per workspace","so don't predict access","A caller without it gets 403"]},{"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,"description":"Name of the dataset.","json_path":"/dataset/name"},{"name":"variables","type":"array","required":true,"description":"List of dataset variables/columns (max 40).","fields":[{"name":"name","type":"string","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/variables"},{"name":"rows","type":"array","required":true,"description":"List of dataset rows (max 100).","fields":[{"name":"row_number","type":"integer","required":true},{"name":"row_data","type":"array","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/rows"}],"intent":"Create a new dataset.","guidance":["create a dataset with variables + rows","Create a new dataset with variables and rows for a project"],"returns":["name","created_at","rows_count","uuid"]},{"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.","json_path":"/exploratory_session/title"},{"name":"description","type":"string","example":"Explore edge cases in the checkout flow.","description":"Optional description.","json_path":"/exploratory_session/description"},{"name":"charter","type":"string","example":"Focus on payment error handling","description":"Testing charter describing the scope and goals.","json_path":"/exploratory_session/charter"},{"name":"timebox_duration","type":"integer","example":60,"description":"Planned duration in minutes.","json_path":"/exploratory_session/timebox_duration"},{"name":"owner","type":"integer","example":42,"description":"TCM user ID of the session owner / assignee.","json_path":"/exploratory_session/owner"},{"name":"assignee","type":"integer","example":42,"description":"Alias for `owner`. TCM user ID of the assignee.","json_path":"/exploratory_session/assignee"},{"name":"folder_id","type":"integer","example":456,"description":"ID of the folder to place this session in.","json_path":"/exploratory_session/folder_id"},{"name":"tags","type":"array","example":["regression","mobile"],"description":"Tags for the session.","json_path":"/exploratory_session/tags"},{"name":"configurations","type":"array","example":[1,3],"description":"Configuration IDs to associate with this session.","json_path":"/exploratory_session/configurations"},{"name":"attachments","type":"array","example":[101,102],"description":"Blob / media attachment IDs.","json_path":"/exploratory_session/attachments"},{"name":"issues","type":"array","description":"Issues to link to this session.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/exploratory_session/issues"}],"intent":"Create an exploratory session","guidance":["body wrapped in exploratory_session (title required)","Creates a new exploratory session within the specified project"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"]},{"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 button \u2014 spinner appeared but order was not created.

","description":"Rich-text HTML content of the log entry.","json_path":"/session_log/content"},{"name":"status","type":"string","values":["pass","fail","bug","retest","block","note"],"example":"fail","description":"Test result status for this log step.","json_path":"/session_log/status"},{"name":"elapsed_time","type":"integer","example":120,"description":"Time elapsed for this step in seconds.","json_path":"/session_log/elapsed_time"},{"name":"defects","type":"array","description":"Defects to link to this log entry.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/session_log/defects"}],"intent":"Create a session log entry","guidance":["add a log entry (rich-text HTML, sanitised on write)","Adds a new log entry to the specified exploratory session","Rich-text HTML content is sanitised before storage"],"returns":["id","status","content","created_at","elapsed_time","session_id","updated_at"]},{"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,"description":"Name of the filter"},{"name":"entity","type":"string","required":true,"values":["testcase","testplan","testrun","exploratory_session","test_plan_test_case"],"description":"Entity type the filter applies to. The server's validator accepts five values (the\nlast two were missing here); an unlisted value returns 400 \"Invalid JSON data\"."},{"name":"is_project_level","type":"boolean","description":"Whether this filter is available at project level"},{"name":"filters","type":"object","required":true,"description":"Filter criteria object containing the actual filter conditions"}],"intent":"Create a new filter","guidance":["save a filter view ({ name, entity, filters, is_project_level })","Create a new saved filter for test cases, test plans, or test runs in a project"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"]},{"method":"POST","path":"/api/v1/projects","mode":"write","entity":"project","body":[{"name":"name","type":"string","required":true,"json_path":"/project/name"},{"name":"description","type":"string","json_path":"/project/description"}],"intent":"Create a new project.","guidance":["Pinned to human approval","Create a new project with name and description"],"returns":["name","description"]},{"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},{"name":"report_type","type":"string","required":true,"values":["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","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_statu"},{"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 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`"},{"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;\n`custom_report` is a user-configured one. For a Requirement Traceability\nreport, `system_generated` makes the server auto-populate\n`report_filters.requirements` with every requirement in the project."},{"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"},{"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":"Integer run ids \u2014 pairs with report_timeframe=specific_test_runs.","json_path":"/report_filters/test_runs"},{"name":"test_plans","type":"array","description":"Integer plan ids \u2014 pairs with report_timeframe=specific_test_plans.","json_path":"/report_filters/test_plans"},{"name":"test_cases","type":"string","description":"Case selection \u2014 pairs with report_timeframe=specific_test_cases. FOLDER-KEYED\n(see SelectionData), never a flat id list: the server resolves the selection\nthrough its folder map, so any other shape yields zero cases and saves an\nUNSCOPED, project-wide report at 200.\n\nSend all three keys. Folder ids are STRING keys; test-case ids are INTEGERS\n(the numeric `id`, not the TC-NNN identifier) \u2014 take bo","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":"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.","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 } }.","json_path":"/report_filters/test_plan_selection"},{"name":"builds","type":"array","json_path":"/report_filters/builds"},{"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\n`Test Run Summary`, `Test Run Detailed Report` and `Test Case Activity` \u2014 on\nthe other six report types the server never reads it and drops it silently.\nPersisted (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`."},{"name":"users","type":"array","json_path":"/mail_to/users"},{"name":"external_mails","type":"array","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\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"},{"name":"time","type":"string","example":"08:00","description":"24-hour HH:MM, UTC.","json_path":"/frequency_details/time"},{"name":"day","type":"string","example":"monday","description":"Required for weekly (lowercase day name) and monthly (integer 1-28).\nOmit for daily.","json_path":"/frequency_details/day"}],"intent":"Create a new scheduled report for a project","guidance":["Create a report configuration under the given project"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"]},{"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","description":"Name of the root folder"},{"name":"notes","type":"string","example":"This folder contains all regression test cases","description":"Optional notes or description for the folder"}],"intent":"Create a root-level folder for test cases in a project","guidance":["Create a new root-level folder for organizing test cases within a project"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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},{"name":"folder_id","type":"integer","description":"ID of the shared-field folder to place the shared step in. Null or omitted means the root (Unassigned)."},{"name":"shared_step_details","type":"array","required":true,"fields":[{"name":"order","type":"integer","required":true},{"name":"step","type":"string"},{"name":"result","type":"string"},{"name":"test_data","type":"string"}]}],"intent":"Create a new shared step in a project","guidance":["Create a new shared step within a project"],"returns":["id","title","step_count","test_case_count"]},{"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},{"name":"test_run_id","type":"string","required":true},{"name":"test_case_id","type":"integer","required":true},{"name":"step_id","type":"integer","required":true}],"body":[{"name":"status_id","type":"integer","json_path":"/test_run_step_result/status_id"},{"name":"description","type":"string","json_path":"/test_run_step_result/description"}],"intent":"Record a per-step result for a case in a run (v1).","guidance":["record a per-step outcome for a step-templated case ({ status_id, description })"]},{"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,"description":"Name of the new folder.","json_path":"/folder/name"},{"name":"notes","type":"string","description":"Description of the new folder.","json_path":"/folder/notes"}],"intent":"create subfolder inside a specific folder","guidance":["Create a new subfolder within a specific folder in a project"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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 the FIRST test case in a project that has no folders (needs create_at_root).","guidance":["Narrow-purpose route: the first case in a project that has zero folders","Verified live on a folderless project: 200, and the folder appears","The route and the flag always travel together","The product's UI derives both from one boolean","no folders exist AND the user picked no folder","A correct refusal, not something to retry: list the folders and pick one"],"returns":["result","step"]},{"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,"fields":[{"name":"name","type":"string","required":true},{"name":"test_case_folder_id","type":"string","required":true},{"name":"attachments","type":"array"},{"name":"automation_state","type":"integer"},{"name":"automation_status","type":"string"},{"name":"case_type","type":"integer"},{"name":"custom_fields","type":"object"},{"name":"description","type":"string"},{"name":"estimate","type":"string"},{"name":"estimated_duration_seconds","type":"integer"},{"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"},{"name":"reviewers","type":"array"},{"name":"send_for_review","type":"boolean"},{"name":"shared_precondition_id","type":"integer"},{"name":"status","type":"integer"},{"name":"tags","type":"array"},{"name":"template","type":"string"},{"name":"template_id","type":"integer"},{"name":"template_step_type","type":"string"},{"name":"test_case_dataset","type":"string"},{"name":"test_case_steps","type":"array"}]},{"name":"create_at_root","type":"boolean"}],"intent":"Create test cases for a folder in a project.","guidance":["create a case in a folder","body wrapped in test_case, name required","Create one or more test cases within a specific folder in a project","If create_at_root is true, the test case will be created at the root of the project"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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","json_path":"/test_plan/name"},{"name":"description","type":"string","json_path":"/test_plan/description"},{"name":"start_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/start_date"},{"name":"end_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/end_date"},{"name":"plan_status","type":"string","description":"Plan status. The list filter accepts new / started / completed.","json_path":"/test_plan/plan_status"},{"name":"owner","type":"integer","description":"Owner user id \u2014 resolve via the project users read first.","json_path":"/test_plan/owner"},{"name":"parent_plan_id","type":"integer","description":"Parent plan's INTEGER id. Set it to create (or re-parent into) a SUB-plan \u2014 the\nresult carries an STP-NNN identifier. Null/omitted means a top-level plan.","json_path":"/test_plan/parent_plan_id"},{"name":"tags","type":"array","json_path":"/test_plan/tags"},{"name":"reviewers","type":"array","description":"Reviewer user ids.","json_path":"/test_plan/reviewers"},{"name":"attachments","type":"array","description":"Attachment ids already uploaded via the attachments surface.","json_path":"/test_plan/attachments"},{"name":"custom_fields","type":"object","json_path":"/test_plan/custom_fields"},{"name":"issues","type":"array","description":"Linked tracker issues. Structured \u2014 NOT a flat array of keys.","fields":[{"name":"issue_id","type":"string"},{"name":"issue_type","type":"string"},{"name":"metadata","type":"object"}],"json_path":"/test_plan/issues"},{"name":"test_runs","type":"string","description":"The plan's linked test runs. Accepts EITHER an array of test-run integer ids, OR a\nbulk-selection object for \"every run matching this filter\". On update this REPLACES\nthe existing link set rather than appending.","json_path":"/test_plan/test_runs"}],"intent":"Create a test plan \u2014 or a SUB-plan, by setting parent_plan_id","guidance":["body wrapped in test_plan","Create a test plan in the project","Everything goes under the test_plan key","test_runs accepts two shapes","Both are honoured server-side","an unknown option is rejected"]},{"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"},{"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":["log a NEW result for a case in a run","Create a new test result for a specific test case within a test run in a project"],"returns":["id","status","backtrace","configuration_id","created_at","created_by_imported","custom_fields","description","error_description","expanded","failure_type","finished_at"]},{"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],"description":"Explicit case ids to pin into the run. Top level, NOT inside `test_run`. Use this or `selection`."},{"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":["Create a new test run for a specific project, which can be used to execute test cases"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"dataset_id","type":"integer","required":true},{"name":"option_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"Delete one option (POST to /delete) \u2014 DESTRUCTIVE, drops values using it.","guidance":["destroys the values that used it","Removing an option deletes the stored values that used it, across every project linked to the dataset","that preserves the values"]},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"Delete a dataset (POST to /delete) \u2014 DESTRUCTIVE, drops its options and values.","guidance":["drops its options and the values the linked projects stored","Removes the option set and unlinks its projects, destroying the values those projects had stored for this field","Name the affected projects before calling"]},{"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}],"intent":"Delete a field definition (POST to /delete) \u2014 DESTRUCTIVE, cascades to stored values.","guidance":["destroys every stored value in every linked project","Name the projects first","Removes the definition and every value stored for it, in every project it is linked to","There is no bin and no undo","Before calling: read the field, say how many projects it is linked to, and name them","If the user only wants it hidden rather than destroyed, that is a different ask"]},{"method":"DELETE","path":"/api/v1/projects/{project_id}/datasets/{uuid}","mode":"destructive","entity":"dataset","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"uuid","type":"string","required":true}],"intent":"Delete a dataset.","guidance":["say it isn't available rather than calling it, and don't substitute by parsing the file yourself","A 422 here means NOT-ENTITLED, not a bad payload","say so and stop, don't retry with a different body","BINDING shape is DIFFERENT from the dataset shape, and this is the usual failure","the dataset's uuid repeated on every column it owns","The binding object has NO top-level uuid or name (both stripped by strong params)"]},{"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":"Delete an exploratory session","guidance":["Deletes an exploratory session"]},{"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":"Delete a session log entry","guidance":["Permanently deletes a log entry from the specified exploratory session"]},{"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 filter","guidance":["entity = testcase | testplan | testrun | exploratory_session","filter_id is an integer","reuse a saved view's filters as the q for a later search or bulk action"]},{"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":"Delete a folder and its contents","guidance":["Deletes a folder by its ID and optionally returns the parent folder's path hierarchy"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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"}],"intent":"Delete a report (this removes the report itself, not just its schedule)","guidance":["returns the remaining reports","merely stopping a report from recurring","The response is the list of remaining reports","There is no bin and no undo","so confirm the report's name with the user first"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"]},{"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","guidance":["affects every test case embedding it","Deletes a shared step from a project"]},{"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":"Delete an attachment from a test case in a folder in a project","guidance":["read the file from that url"],"returns":["id","byte_size","content_type","filename"]},{"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":"Delete a test plan (or sub-plan) \u2014 no bin, no undo","guidance":["The server performs no plan-type check","so this deletes a sub-plan by its own integer id exactly the same way","Deleting a parent plan is destructive to its children"]},{"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 test result","guidance":["Prefer PATCHing the latest result to correct a mistake","Deleting a result removes an execution record","the case's latest status is recomputed from what remains"]},{"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"}],"intent":"delete test run","guidance":["The discovered ids must be CARRIED INTO the create body","discovery alone puts nothing in the run","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","It is validated before the selection","so a bare 400 'invalid data' means a bad test_run field"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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},{"name":"duplicate_id","type":"integer","required":true}],"body":[{"name":"discard_reason","type":"string","description":"Short reason code / label for why this isn't a duplicate."},{"name":"user_feedback","type":"string","description":"Free-text feedback from the user."}],"intent":"Discard a duplicate suggestion \u2014 dismisses it WITHOUT touching test cases","guidance":["DISCARD a suggestion","dismisses it WITHOUT touching either test case","The safe default when the user says it's not a duplicate or is unsure","Mark a recommendation as discarded","This is the safe way to dismiss a suggestion: it changes only the recommendation's status to discarded and leaves both test cases exactly as they are","discard_reason and user_feedback are optional but feed the dedupe model"]},{"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":"Desired export format. Note this is a STRING here, while the same-named field\non `send_email_now` is an ARRAY."},{"name":"required","type":"object","description":"Optional column selection, honoured only for `Test Run Detailed Report`\n(ignored for every other type). Shape:\n`{ \"\": { \"fields\": [\"id\",\"title\",...] } }`. Omit to get the report's\ndefault columns."}],"intent":"Initiate a download of a report in a specified file format","guidance":["Request generation and download channel for the specified report"],"returns":["channel"]},{"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"},{"name":"custom_fields","type":"object","example":{"123":"option_value","456":["multi","select"]},"json_path":"/test_case/custom_fields"},{"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"},{"name":"preconditions","type":"string","json_path":"/test_case/preconditions"},{"name":"priority","type":"integer","example":483,"json_path":"/test_case/priority"},{"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\".","json_path":"/test_case/reviewers"},{"name":"status","type":"integer","example":496,"json_path":"/test_case/status"},{"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":"string","description":"Dataset binding for a data-driven case. On this path it is applied only when `is_dataset_modified` is true.","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"}],"intent":"Partially edit a test case (v2)","guidance":["PREFERRED partial update","send ONLY changed fields","Partially update the details of a test case within a specific folder in a project","it is the authoritative list","send test_case_steps instead - estimate is accepted and then dropped","estimated_duration_seconds is the one that persists"],"returns":["result","step"]},{"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"},{"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"},{"name":"custom_fields","type":"object","json_path":"/test_case/custom_fields"},{"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"},{"name":"name","type":"string","json_path":"/test_case/name"},{"name":"owner","type":"integer","json_path":"/test_case/owner"},{"name":"preconditions","type":"string","json_path":"/test_case/preconditions"},{"name":"priority","type":"integer","json_path":"/test_case/priority"},{"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"},{"name":"tags","type":"array","json_path":"/test_case/tags"},{"name":"template","type":"string","json_path":"/test_case/template"},{"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":"string","description":"Dataset binding for a data-driven case. On create the mappings are always regenerated from this, so `is_dataset_modified` is ignored here.","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","json_path":"/test_case/test_case_steps"}],"intent":"Edit a test case","guidance":["Update the details of a test case within a specific folder in a project","Omitted fields are left untouched EXCEPT template, template_id and review_status, which are reset to defaults"],"returns":["result","step"]},{"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"}],"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],"description":"Explicit case ids to pin into the run. Top level, NOT inside `test_run`. Use this or `selection`."},{"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":"Edit Test Run","guidance":["Update the details of a specific test run within a project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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","pdf","excel"],"example":"csv","description":"Export file format"},{"name":"start_date","type":"string","example":"2025-01-01","json_path":"/filters/start_date"},{"name":"end_date","type":"string","example":"2025-12-31","json_path":"/filters/end_date"},{"name":"status","type":"array","example":["passed","failed"],"json_path":"/filters/status"}],"intent":"Export dashboard analytics data","guidance":["Initiates an asynchronous export of dashboard analytics data in the specified format","The export is processed via background job and returns an export ID for tracking","Async Processing: Data export runs via Sidekiq ExportDashboardWorker::FileExportWorker","Supported Formats: CSV, PDF, Excel (based on type parameter)"],"returns":["export_id"]},{"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":"Get active test run result statistics","guidance":["Retrieve statistics of active test runs for a specific project"],"shape":"discovered"},{"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":"Get archived test cases.","guidance":["Retrieve a list of archived test cases in a specific project"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"Count archived test plans","guidance":["count archived plans","Returns {success, count}","the number of archived plans in the project"],"shape":"discovered"},{"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 automation stats analytics","guidance":["Retrieve automation statistics for a specific project"],"returns":["automated_coverage","automated_test_cases","empty_data","manual_test_cases","total_test_cases"]},{"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":"Get all the closed test runs for a project","guidance":["Retrieve a list of all closed test runs for a specific project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"Get closed test run analytics","guidance":["Retrieve statistics of closed test runs for a specific project"],"returns":["month"]},{"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":"Get closed test runs split analytics","guidance":["Retrieve split statistics of closed test runs for a specific project"],"returns":["name"]},{"method":"GET","path":"/api/v1/projects/{project_id}/configurations","mode":"read","entity":"configuration","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Get configurations for a project","guidance":["Retrieve a list of configurations for a specific project"],"returns":["id","name","created_at","group_id","is_suggested","record_status"]},{"method":"GET","path":"/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets/{dataset_id}","mode":"read","entity":"custom_field","path_params":[{"name":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"Read one dataset \u2014 its project links and option set.","guidance":["read one dataset's project links and options"],"shape":"discovered"},{"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}],"intent":"Read one custom field definition, with its datasets and project links.","guidance":["read one definition + its datasets and linked projects","do this BEFORE any update, which is a full replace","Options are NOT inline","Datasets come back WITHOUT their options inline","Read this before any update","so you need the current values to avoid clearing them"],"shape":"discovered"},{"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},{"name":"entity_type","type":"string","required":true,"values":["test-case","test-result","test-plan"]},{"name":"field_id","type":"string","required":true}],"query":[{"name":"q","type":"string"}],"intent":"Get the allowed values/options of one custom field.","guidance":["If nothing matches, the field doesn't exist in that workspace","say so rather than guessing a field id","Multi-dropdowns need OPTION IDS, not labels","That legacy family writes a table local to the Rails app that is DEPRECATED and that nothing reads","It is blocked at the gate","testhub owns definitions"],"shape":"discovered","paginated":true},{"method":"GET","path":"/api/v1/projects/{project_id}/datasets/{uuid}","mode":"read","entity":"dataset","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"uuid","type":"string","required":true}],"intent":"Get a specific dataset.","guidance":["read one dataset by UUID","Retrieve a specific dataset by its UUID including all variables and rows"],"returns":["name","created_at","rows_count","uuid"]},{"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},{"name":"uuid","type":"string","required":true}],"intent":"Get test cases linked to a dataset","guidance":["list the test cases bound to this dataset (paged p)","Returns the test cases linked to a dataset, identified by its UUID"],"shape":"discovered","paginated":true},{"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"}],"intent":"Get dataset variables/columns.","guidance":["BINDING shape is DIFFERENT from the dataset shape, and this is the usual failure","the dataset's uuid repeated on every column it owns","The binding object has NO top-level uuid or name (both stripped by strong params)","so do NOT copy the dataset's read shape into it","Never report a dataset as linked from the 200 alone","A dataset is addressed by a UUID (the dataset path param), NOT an integer"],"returns":["name","uuid"],"paginated":true},{"method":"GET","path":"/api/v1/projects/{project_id}/ai-duplicates/status","mode":"read","entity":"duplicate","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Dedupe job status \u2014 use this to tell \"feature off\" from \"no duplicates\"","guidance":["ALWAYS call this when the list is empty","Status of the project's most recent dedupe run"],"returns":["status"]},{"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},{"name":"duplicate_id","type":"integer","required":true}],"intent":"Read the pre-merge snapshot of a MERGED duplicate's source test case","guidance":["after a merge, read the pre-merge snapshot of the case that was folded away","After a merge, the source case no longer exists independently","this returns the snapshot captured at merge time","the only way to show the user what was folded away","Only works for status merged (404 otherwise), and the snapshot may legitimately be absent, which also surfaces as a 404 with error: \"Source test case snapshot not available\"","Treat that as \"not retained\", not as an error to retry"],"shape":"discovered"},{"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},{"name":"duplicate_id","type":"integer","required":true}],"intent":"Read one suggested duplicate, with its test cases resolved","guidance":["read one suggestion with its test cases resolved","do this BEFORE merging so the user can see what would be destroyed","Only status=suggested is readable","Full detail for one recommendation, including the actual test cases it links","so you can show the user what would be merged","Only status suggested is readable here"],"shape":"discovered"},{"method":"GET","path":"/api/v1/projects/{project_id}/{entity}/filter-details","mode":"read","entity":"project","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"entity","type":"string","required":true,"values":["test-cases","trtc","test-runs"],"example":"test-cases"}],"query":[{"name":"q[query]","type":"string","example":"login functionality"},{"name":"q[folder_ids]","type":"string","example":"3306878,3669741,5422801,5422802"},{"name":"q[status]","type":"string","example":"9319,9321"},{"name":"q[priority]","type":"string","example":"550376,9261"},{"name":"q[automation_state]","type":"string","example":"18172471,18172473"},{"name":"q[case_type]","type":"string","example":"9379,9375"},{"name":"q[owner]","type":"string","example":"user123,user456"},{"name":"q[assignee]","type":"string","example":"user789,user101"},{"name":"q[tags]","type":"string","example":"regression,smoke"},{"name":"q[created_at]","type":"string","example":"2024-01-01..2024-12-31"},{"name":"q[updated_at]","type":"string","example":"2024-01-01..2024-12-31"}],"intent":"Get filter details for entity","guidance":["Retrieve filter details for a specific entity type (test-cases, trtc, or test-runs) within a project"],"returns":["id","colour","entity_type","field_name","field_value","internal_name"]},{"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":"Get an exploratory session","guidance":["read one session by INTEGER id","Returns a single exploratory session by its ID"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"]},{"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"]},{"name":"start_date","type":"string"},{"name":"end_date","type":"string"},{"name":"session_ids","type":"string"}],"intent":"Exploratory Session Summary \u2014 live view, no saved report needed","guidance":["exploratory-session summary WITHOUT creating a report","Computes an exploratory-session summary on demand","there is no Schedule row behind it","Use it to answer \"summarise our exploratory sessions\" without creating a report first","Returns session counts, bugs found, top testers (resolved to user objects) and the session list"],"shape":"discovered"},{"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":"Get filter details","guidance":["Retrieve details of a specific saved filter by its ID","It does NOT validate or strip invalid custom-field references","a filter saved with a non-existent custom-field id is returned unchanged","A missing or deleted filter comes back as 400 {\"success\": false, \"message\": \"Filter not found\"}, not 404"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"]},{"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":"Get summary of entities to be deleted when removing a folder","guidance":["PREVIEW what deleting a folder will remove before calling rm","Provides a summary of all entities (test cases, recordings, sub-folders) that would be deleted if the folder is removed"],"returns":["active_test_case_count","archived_test_case_count","sub_folders","test_recordings_count"]},{"method":"GET","path":"/api/v1/projects/{project_id}/repository/mapping","mode":"read","entity":"folder","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Get the project's folder tree (v1).","guidance":["the whole project folder TREE in one call (structure array)"],"shape":"discovered"},{"method":"GET","path":"/api/v1/group/users-v2","mode":"read","entity":"user","query":[{"name":"q","type":"string"}],"intent":"Get group users V2 with pagination","guidance":["Retrieve a paginated list of users in the group with search and filtering capabilities, WITHOUT project scoping","used by the Global Search filter dropdowns","Search by user IDs (comma-separated) or by a name string"],"returns":["id","browserstack_user_id","email","full_name","group_id","onboarded","reports_and_notification_enabled"]},{"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":"Get issues count analytics","guidance":["Retrieve the count of issues for a specific project"],"returns":["label"]},{"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","guidance":["read ONE project by its INTEGER id","so for an integer id this is the right read","Do NOT translate first just to fetch the project","It serves two jobs at once: (1) READ","returns the project's full detail (name, description, permissions, \u2026)","In other words it is NOT translation-only"],"returns":["id","identifier","name","created_at","description","jira_mapped","starred","test_cases_count","test_plans_count","test_runs_count","thProjectId","user_role"]},{"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}],"intent":"Get project-specific custom fields V2 for test cases","guidance":["START HERE for CUSTOM fields","Does NOT expose system-field option ids","Retrieve custom fields, default fields, and system fields for test cases in a specific project (V2 format)"],"returns":["id","default_value","entity_type","field_name","field_type","group_id","is_bulk_editable","is_filterable","is_required","linked_projects_count","place_holder_text","system_name"]},{"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"]}],"intent":"Get project settings","guidance":["Returns an array of enabled setting keys"],"returns":["in_review_count","test_plan_in_review_count"]},{"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"}],"intent":"Get project users V2 with pagination","guidance":["Retrieve paginated list of users in the group with search and filtering capabilities","Search by user IDs (comma-separated) or by name string"],"returns":["id","browserstack_user_id","email","full_name","group_id","onboarded","reports_and_notification_enabled"]},{"method":"GET","path":"/api/v1/projects/basic","mode":"read","entity":"project","query":[{"name":"q[query]","type":"string","example":"nishchay3"},{"name":"q[identifier]","type":"string","example":"PR-60863"}],"intent":"Get paginated projects (lean payload) \u2014 use this to list or count projects","guidance":["LIST or COUNT projects","so you can reach a true total without truncating","Those two are the only recognised keys"],"returns":["id","name","description","import_id","normalisedName","starred","thProjectId"],"paginated":true},{"method":"GET","path":"/api/v1/projects/minify","mode":"read","entity":"project","intent":"Get all projects (minified dropdown) \u2014 has NO name search","guidance":["never use it to resolve a named project or to count them","Minified list of active projects, for dropdown-style selection","It accepts no q in any form","the backend call has no query option","so it can only page through everything","Do NOT use it to resolve a project the user named"],"returns":["id","name","description","import_id","normalisedName","starred","thProjectId"],"paginated":true},{"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},{"name":"attachment_id","type":"integer","required":true}],"intent":"Get download URL for a report attachment","guidance":["Retrieve a secure URL to download a specific report attachment"],"returns":["url"]},{"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"},{"name":"report_data_only","type":"boolean"},{"name":"today","type":"string","example":"2025-05-13"}],"intent":"Retrieve a scheduled report's config and data \u2014 the general report read","guidance":["works for every report type","Full configuration plus computed data for a report of ANY type","request the section and look at the result","which is a real finding","Pass sections to compute the sections you need","several in ONE call, cheaper than one request per section"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"],"paginated":true},{"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"}],"query":[{"name":"widget_type","type":"string"},{"name":"segment","type":"string"}],"intent":"Fetch one report widget/section \u2014 works for EVERY report type.","guidance":["Section names are validated per type","*_drilldown sections return paginated ROWS, not aggregates","Returns a single section's data for any report type","This is the general section read","so when you need SEVERAL sections, prefer that form with a comma-separated sections list and take them in one call rather than one request per section","Most sections return the computed aggregate for a widget"],"shape":"discovered","paginated":true},{"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","requirement_traceability_report"]}],"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":"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`"},{"name":"sections","type":"string","example":"test_run_data,test_run_status"},{"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"]},{"name":"segment","type":"string"}],"intent":"Get summary statistics for a scheduled report \u2014 run and traceability types ONLY","guidance":["400s on every other type","so not for Test Case Activity or Test Plan Summary","Summary statistics for a scheduled report, for THREE report types only","including every other type in ReportType","This is not the general report-read","optionally with sections"],"shape":"discovered","paginated":true},{"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 of the folder to search for"},{"name":"include_folder_hierarchy","type":"boolean","description":"Whether to include folder hierarchy in the response"}],"intent":"Get all folders and subfolders present in the projects.","guidance":["list root folders (paged with p","Returns all folders and subfolders in a project if it exists in TestHub"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"],"paginated":true},{"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":"Get already selected testcases for a tracebility report","guidance":["Retrieve the already selected testcases for a tracebility report"],"returns":["select_all","unique_test_case_count"]},{"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":"paginated","type":"boolean"}],"intent":"Get shared steps","guidance":["read one shared step with its ordered step details","Retrieves a list of shared steps for a project"],"returns":["id","title"],"paginated":true},{"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":"q[query]","type":"string"},{"name":"pre_fetch","type":"boolean"}],"intent":"Get all shared steps for a project","guidance":["Returns a list of all shared steps within a project"],"returns":["id","title","step_count","test_case_count"],"paginated":true},{"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"}],"intent":"THE source for a system field's option ids (priority/status/case_type/automation_state).","guidance":["a single attachment-URL field is enough to push the response past the ~30 KB cap","Supports q to search the option list and p to page it, which matters","a workspace can accumulate dozens of options on a single field"],"shape":"discovered","paginated":true},{"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":"Get all attachments for a test case in a folder in a project","guidance":["list a case's attachments","Retrieve all attachments associated with a specific test case within a folder","CURRENTLY RETURNING 500","Verified live against five different real test cases, with both the case's true folder_id and a mismatched one","a reproducible server error, not a bad request","Do NOT retry it and do not report it to the user as their mistake"],"returns":["id","byte_size","content_type","filename"]},{"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":"Get a test case by INTEGER id (v1, folder-scoped) \u2014 full detail + its TC-NNN identifier","guidance":["READ one case by INTEGER id (folder-scoped)","Read a test case by its INTEGER id","so for an integer id this v1 read is the ONLY way to fetch the case","Two jobs at once: (1) READ","NOT translation-only","Don't fall back to listing the folder to find the case: if you have the integer id, read it here directly"],"returns":["id","identifier","title"]},{"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":"Get test case count trend analytics","guidance":["Retrieve the trend of test case counts for a specific project"],"returns":["field"]},{"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","example":"folder_path"}],"intent":"Get Test Case Details","guidance":["full detail (steps, custom fields, issues) before editing","read this, improve, then write back"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"Get test case histories","guidance":["list all version records for a test case","Retrieve all history records for a specific test case"],"returns":["id","comment","created_at","entity_id","entity_type","group_id","project_id","source","user_id","version_id","version_name"]},{"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}],"intent":"Get test case history diff","guidance":["compare two versions","versions[] with exactly two ids (PAID plan)","Retrieve the diff of a specific history record for a test case"],"returns":["id","order","result","shared_step_id","step"]},{"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":"Get a specific test case history","guidance":["Retrieve details of a specific test case history by its ID"],"returns":["id","comment","created_at","entity_id","entity_type","group_id","project_id","source","user_id","version_id","version_name"]},{"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":"Get tags and metadata for a test case","guidance":["read a case's current tags","Retrieve the tags and metadata associated with a specific test case in a project"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"Get test case type split analytics","guidance":["Retrieve the split of test case types for a specific project"],"returns":["name","field","value","y"]},{"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"}],"query":[{"name":"required[fields]","type":"string","values":["count"]}],"intent":"Get paginated test cases for a test run","guidance":["page the cases in a run (each row is the case you log results against)","Retrieve a paginated list of test cases for a specific test run in a project"],"shape":"discovered"},{"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":"required[fields]","type":"string","example":"owner,priority"},{"name":"required[custom_fields]","type":"string","example":"121,119"},{"name":"all_folders","type":"string","values":["true"],"example":"true"}],"intent":"List a project's test cases \u2014 REQUIRES all_folders=true to be project-wide.","guidance":["Without all_folders=true this returns only ONE folder's cases","the project's first root folder","So a project-wide question answered from a bare call silently under-counts by whatever sits in every other folder, and nothing in the response says so","all_folders is compared as the STRING 'true' (all_folders = params['all_folders'] == 'true')","A JSON boolean true, 1, TRUE or any other value all fail that check and silently fall back to the single-folder scope"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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":"Get a test plan by INTEGER id (v1) \u2014 full detail + its TP-NNN identifier","guidance":["READ one plan by INTEGER id","Read a test plan by its INTEGER id (project integer id in the path)","Don't translate first just to read the plan","this returns its full detail directly","Two jobs at once: (1) READ","the plan's full detail (under test_plan"],"returns":["identifier","name"]},{"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"},{"name":"test_run_id","type":"integer"},{"name":"today","type":"string"}],"intent":"Execution-trend chart data for ONE plan or ONE run (XOR)","guidance":["execution-trend chart data for ONE plan or ONE run","pass test_plan_id XOR test_run_id (both or neither is a 400)","Time-series execution trend backing the Insights tab chart","Scope it with exactly one of test_plan_id or test_run_id","Passing both returns 400 \"Provide either test_plan_id or test_run_id, not both\"","passing neither returns 400 \"test_plan_id or test_run_id is required\""],"shape":"discovered"},{"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 a test plan","guidance":["chart data for the exploratory sessions linked to a plan","Aggregated chart data for exploratory sessions linked to the plan","the other half of the Insights tab","Like execution-trend, this is chart data only","insights widgets themselves are not a CRUD resource here"],"shape":"discovered"},{"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":"include_sub_test_plan_runs","type":"boolean"},{"name":"compact","type":"boolean"},{"name":"is_archived","type":"boolean"}],"intent":"List the test runs linked to a test plan","guidance":["list the runs a plan groups","Paginated list of the runs a plan groups","that field replaces the link set rather than appending to it","include_sub_test_plan_runs=true also returns runs belonging to the plan's sub-plans"],"shape":"discovered","paginated":true},{"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}],"intent":"Get paginated custom fields for test results","guidance":["Retrieve paginated list of custom fields configured for test results in a project"],"returns":["id","default_value","entity_type","field_name","field_type","field_user_name","is_bulk_editable","is_filterable","is_required","optional","placeholder"],"paginated":true},{"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"},{"name":"test_case_id","type":"integer","required":true}],"intent":"Get test results for a test case in a test run","guidance":["read a case's results within a run","Retrieve a list of test results for a specific test case within a test run in a project"],"returns":["id","status","backtrace","configuration_id","created_at","created_by_imported","custom_fields","description","error_description","expanded","failure_type","finished_at"]},{"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":["READ a run by INTEGER id","Read a test run by its INTEGER id (with the project's integer id in the path)","Don't translate first just to read the run","this returns its full detail directly","Two jobs at once: (1) READ","NOT translation-only"],"returns":["id","identifier","name","uuid"]},{"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},{"name":"test_run_id","type":"string","required":true}],"intent":"Get a run with its per-case rows (v1).","guidance":["the run with its per-case rows (did it pass?)"],"shape":"discovered"},{"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"}],"intent":"Get custom field values for test results in test runs","guidance":["Retrieve default field values and optionally custom fields for test results (TRTC - Test Run Test Case)"],"returns":["id","applies_to_all_projects","default_value","entity_type","field_name","field_type","is_required","link_to_future_projects","place_holder_text"]},{"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.","json_path":"/selection/select_all"},{"name":"unique_test_case_count","type":"integer","example":2,"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.","json_path":"/selection/folders"},{"name":"shared_selection","type":"object"}],"intent":"get selected test runs of a project","guidance":["Retrieve selected test runs for a specific project based on the provided selection criteria"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"ignore_run_type","type":"boolean"},{"name":"include_test_plan","type":"boolean"}],"intent":"Get all the test runs for a project","guidance":["list the project's (open) runs, paged with p","Retrieve a list of all test runs for a specific project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"Import a CSV dataset \u2014 NOT SUPPORTED in this profile","guidance":["NOT SUPPORTED in this profile","say CSV import isn't available","CSV import is not supported here","If asked to import a dataset from CSV, say it isn't available and point to the Test Management UI"]},{"method":"GET","path":"/api/v1/activities","mode":"read","entity":"activity","query":[{"name":"project_id","type":"integer"},{"name":"cursor","type":"string"},{"name":"limit","type":"integer"},{"name":"actor_id","type":"integer"},{"name":"starred_only","type":"boolean"},{"name":"entity_type","type":"array"}],"intent":"Read the activity feed (audit trail) \u2014 CURSOR paged, 15-day window","guidance":["WHO changed WHAT recently","group-wide audit trail","Group-wide audit trail of who changed what","Read-only: activity events cannot be created, edited, or deleted","Two things differ from every other list in this profile: - Cursor pagination, not page numbers","so you cannot jump to a page or read a total"],"returns":["no_starred_projects"]},{"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}],"intent":"List archived test plans","guidance":["where a 'missing' plan usually is, and the source of ids for bulk-retrieve","Paginated list of the project's archived plans","so if a plan the user names seems missing, look here before concluding it was deleted"],"shape":"discovered","paginated":true},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"List a dataset's options with their ids.","guidance":["list a dataset's options WITH their ids","the ids a filter or a test-case write needs"],"shape":"discovered"},{"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"},{"name":"q[uuid]","type":"string"}],"intent":"List datasets for a project.","guidance":["list datasets (paged p","Retrieve a paginated list of datasets for a project with optional filtering by name or UUID"],"returns":["name","created_at","rows_count","test_cases_count","updated_at","uuid"],"paginated":true},{"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":"entity_type","type":"string"},{"name":"entity_id","type":"integer"},{"name":"duplicates","type":"string"}],"intent":"List AI-suggested duplicate test cases \u2014 an empty list may mean the feature is OFF","guidance":["LIST the duplicate suggestions awaiting review","An EMPTY list may mean the feature is off","List the AI dedupe recommendations awaiting review in a project","Only duplicates with status suggested are returned","once merged, archived, or discarded they leave this list","An empty result is ambiguous"],"shape":"discovered","paginated":true},{"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}],"intent":"List defects for an exploratory session","guidance":["List defaults to active sessions","the parent project takes the INTEGER project id (v1)","defects are the issues linked to the session"],"returns":["issue_id","issue_type"],"paginated":true},{"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}],"intent":"List logs for an exploratory session","guidance":["page the session's log entries","Returns a paginated list of log entries for the specified exploratory session"],"returns":["id","status","content","created_at","elapsed_time","session_id","updated_at"],"paginated":true},{"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":"q[session_state]","type":"string","values":["active","closed"],"example":"active"},{"name":"q[assignee]","type":"integer","example":42},{"name":"q[owner]","type":"integer","example":42},{"name":"q[created_by]","type":"integer","example":10},{"name":"q[created_at_from]","type":"string","example":"2026-01-01"},{"name":"q[created_at_to]","type":"string","example":"2026-01-31"},{"name":"q[tags][]","type":"array","example":["regression","payment"]},{"name":"q[configuration_ids][]","type":"array","example":[1,2]},{"name":"q[search]","type":"string","example":"checkout"},{"name":"q[status]","type":"string","example":"pass"}],"intent":"List exploratory sessions for a project","guidance":["list sessions (defaults to active","Returns a paginated list of exploratory sessions"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"],"paginated":true},{"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","type":"string","required":true,"values":["testcase","testplan","testrun","exploratory_session","test_plan_test_case"]}],"intent":"List filters","guidance":["list saved filter views (optional entity = testcase|testplan|testrun|exploratory_session)","Retrieve a paginated list of saved filters for a project, optionally filtered by entity type"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"],"paginated":true},{"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":"List subfolders and folder metadata for a specific folder in a project","guidance":["one folder's detail + its direct subfolders + ancestors","Retrieve a list of subfolders and their metadata for a specific folder in a project"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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":"required[fields]","type":"string","example":"owner,priority"},{"name":"required[custom_fields]","type":"string","example":"121,119"}],"intent":"List the test cases in one folder","guidance":["Page through the cases directly inside a folder, by the folder's INTEGER id","Use this when the user has already named or selected a folder","Rows are verbose and list reads truncate around 30 KB"],"shape":"discovered","paginated":true},{"method":"GET","path":"/api/v1/projects","mode":"read","entity":"project","query":[{"name":"q","type":"string","example":"nishchay3"},{"name":"sort_by","type":"string"},{"name":"starred","type":"boolean"},{"name":"shared","type":"boolean"},{"name":"is_hidden_projects","type":"boolean","example":true}],"intent":"List projects for a group.","guidance":["Paginated list of the group's projects","query and page are not read by the server","so use this to FIND a project, not to enumerate them"],"returns":["id","identifier","name","created_at","description","import_id","project_creator","test_cases_count","test_runs_count"],"paginated":true},{"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"},{"name":"q[scheduled]","type":"string","values":["true","false"]}],"intent":"List all the scheduled reports","guidance":["list the project's report schedules (paged p","Retrieve a paginated list of schedules"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"],"paginated":true},{"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":"q","type":"string"}],"intent":"List test-case tags (paginated).","guidance":["list a project's test-case tags (paged with p, optional q)"],"shape":"discovered","paginated":true},{"method":"GET","path":"/api/v1/admin-v2/settings/templates","mode":"read","entity":"","query":[{"name":"entity_type","type":"string","values":["TestCase","TestResult","TestPlan","ExploratorySession"]},{"name":"project","type":"integer"},{"name":"name","type":"string"}],"intent":"List the workspace's test-case templates \u2014 THE source for `template_id`.","guidance":["Ids are per-workspace","so one carried over from another workspace (or from an example) produces the generic 400 \"The API request is invalid or improperly formed\" on a test-case create, with nothing naming the offending field","Call this when the user asked for a named template, or to confirm a template id before reusing one","One call therefore yields both fields","NOTE THE CASING: entity_type here is CamelCase (TestCase), unlike the hyphenated test-case used in path segments elsewhere","Passing test-case returns an empty list with a 200"],"shape":"discovered","paginated":true},{"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":"include_sub_plans","type":"boolean"},{"name":"status","type":"string","values":["new","started","completed"]},{"name":"sort_by","type":"string"},{"name":"minify","type":"boolean"}],"intent":"List test plans in a project (optionally including sub-plans)","guidance":["include_sub_plans=true to see sub-plans too","Paginated list of the project's test plans","This is how you find a plan's integer id before reading, updating, or deleting it","so never try to find a plan through search","Without it you see only top-level plans and a plan's children are invisible"],"shape":"discovered","paginated":true},{"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"]},{"name":"field_source","type":"string","values":["system","custom","all"]},{"name":"field_type","type":"string"},{"name":"q","type":"string"}],"intent":"THE canonical workspace field list (system + custom) \u2014 start here for definitions.","guidance":["workspace-wide list of DEFINITIONS across all projects","'does a field called X exist anywhere?'","The authoritative list (testhub-backed)","This is the authoritative definition list: it is backed by testhub, which owns custom-field definitions","That legacy collection reads a DEPRECATED table local to the Rails app that nothing else consults","its contents are unrelated to the real fields"],"shape":"discovered","paginated":true},{"method":"POST","path":"/api/v1/projects/{project_id}/tags/merge","mode":"write","entity":"tag","path_params":[{"name":"project_id","type":"integer","required":true}],"body":[{"name":"source_id","type":"integer","required":true},{"name":"target_id","type":"integer","required":true},{"name":"entity_type","type":"string","required":true,"values":["test_case","test_run","test_plan","shared_step"]}],"intent":"Merge one tag into another (v1). Admin-gated (tags_management).","guidance":["merge one tag into another ({ source_id, target_id, entity_type })"]},{"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","required":true,"example":123,"description":"ID of the new parent folder (internal APIs)"},{"name":"new_base_project_id","type":"integer","example":456,"description":"Optional destination project ID for cross-project moves"},{"name":"user_action","type":"string","example":"move_folder"}],"intent":"Move a test case folder to a different parent folder","guidance":["move a folder ({ new_base_folder_id, new_base_project_id })","cross-project moves run async","Move a test case folder to a new parent folder within the same project","This operation updates the folder's hierarchy without changing its contents"],"returns":["async","unique_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","required":true,"description":"Name of the new folder.","json_path":"/folder/name"},{"name":"notes","type":"string","description":"Description of the new folder.","json_path":"/folder/notes"}],"intent":"rename a folder in a project","guidance":["Rename an existing folder within a specific project"],"returns":["request_trace_id"]},{"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],"description":"List of folder IDs to reorder"},{"name":"prev","type":"integer","example":101,"description":"ID of the folder that will precede the moved folder"},{"name":"next","type":"integer","example":103,"description":"ID of the folder that will follow the moved folder"}],"intent":"Reorder folders in a project","guidance":["Reorder folders within a project by specifying the current order and optionally the previous and next folder IDs"],"returns":["request_trace_id"]},{"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":"ID of the test case that will be above the moved ones.","json_path":"/re_order/top_test_case"},{"name":"bottom_test_case","type":"string","description":"ID of the test case that will be below the moved ones.","json_path":"/re_order/bottom_test_case"},{"name":"test_cases","type":"array","required":true,"description":"Array of test case IDs to be moved.","json_path":"/re_order/test_cases"}],"intent":"Reorder test cases within a folder of a project","guidance":["reorder cases within a folder","Reorders test cases in a folder by placing them between top_test_case and bottom_test_case"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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. Passed in the BODY, not the path."},{"name":"merge_action","type":"string","required":true,"description":"`merge` folds source into target; any other value archives."},{"name":"source_testcase_id","type":"integer","description":"Required for merge \u2014 the case that will cease to exist independently."},{"name":"target_testcase_id","type":"integer","description":"Required for merge \u2014 the case that survives."}],"intent":"Resolve a duplicate by MERGING or ARCHIVING \u2014 destroys or hides a test case","guidance":["RESOLVE a suggestion","merge_action=merge folds source_testcase_id into target_testcase_id (DESTRUCTIVE to a test case), anything else archives","duplicate_id goes in the BODY","Act on one dedupe recommendation","merge_action selects what happens: - \"merge\"","folds source_testcase_id into target_testcase_id"]},{"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":"Restore a specific history entry","guidance":["roll the case back to this version (PAID plan","Restore a specific history entry by its ID"]},{"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":"duplicates","type":"string","description":"Duplicate-type filter."},{"name":"owner","type":"string","description":"Owner tab \u2014 scopes to the caller's own duplicates vs all."}],"intent":"Filtered search over suggested duplicates","guidance":["filtered search over suggestions (owner tab, duplicate type, test-case attributes)","Subject to the identical flag caveat: an empty list may mean the feature is off rather than that there are no duplicates"],"shape":"discovered","paginated":true},{"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"}],"query":[{"name":"q","type":"string"}],"intent":"Search group tags for an entity type","guidance":["search tags across the whole group (entity_type = test-case|test-run|test-plan)","Retrieve a paginated list of tags for the given entity type across the group (no project scoping)","used by the Global Search filter dropdowns"],"returns":["name","created_at","usage_count"],"paginated":true},{"method":"GET","path":"/api/v1/projects/{project_id}/{entity}/search","mode":"read","entity":"project","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"entity","type":"string","required":true,"values":["test-cases","test-runs","test-plans","reports"]}],"query":[{"name":"q[query]","type":"string","example":"tc"},{"name":"q[folder_id]","type":"integer","example":29529465},{"name":"use_bstack_id","type":"string","values":["0","1"]},{"name":"include_test_plan","type":"boolean"}],"intent":"Search for entities within a project","guidance":["Returns paginated results matching the search criteria"],"shape":"discovered","paginated":true},{"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":"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":["Note: Array values in the q parameter are automatically converted to comma-separated strings for compatibility with existing search logic"],"shape":"discovered","paginated":true},{"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"}],"intent":"Search users with access to a project","guidance":["Retrieves a paginated list of users who have access to the specified project","Returns user details including permissions, feature flags, and product roles"],"returns":["id","browserstack_user_id","customisable_dashboard_accessible","full_name","group_id","has_access","is_build_listing_enabled","is_delighted_visible","is_jira_app_project_panel_visible","is_lcnc_explore_dismissed","is_new_project_settings_visible","is_onboarding_project_header_visible"],"paginated":true},{"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"}],"intent":"Search test case tags","guidance":["Retrieves a paginated list of tags associated with test cases in the project","Returns both simple tag strings and detailed tag objects with metadata"],"returns":["name","created_at","usage_count"],"paginated":true},{"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":"query","type":"string"},{"name":"use_bstack_id","type":"string","values":["0","1"]}],"intent":"Search test cases in a project","guidance":["and see pagination-and-filtering for the key table and each value's source","there is NO top-level values array, and looking for one finds nothing","so it is routinely cut off and appears absent","NEVER probe candidate integers for an id","The four system option fields","a name silently returns 0 because the server matches an id column"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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","example":["qa-leads@company.com"],"description":"Recipient addresses. MUST be an array, even for one address."},{"name":"file_type","type":"array","example":["pdf"],"description":"Formats to attach. MUST be an array. Defaults to [\"pdf\"]."}],"intent":"Email a report immediately (async job).","guidance":["email the report immediately (async job)","Kicks off a background send","a 200 means the job was queued, NOT that mail was delivered","don't report it as sent","Both body fields are ARRAYS and are rejected with 400 \" should be an array\" if sent as scalars","Omitting emails sends to the report's configured recipients"]},{"method":"POST","path":"/api/v1/projects/{project_id}/folder/{folder_id}/undo-rm","mode":"write","entity":"folder","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"folder_id","type":"integer","required":true}],"intent":"Undo folder deletion","guidance":["Restores a previously deleted folder and returns its metadata along with path hierarchy"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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":"dataset_id","type":"integer","required":true},{"name":"option_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"body":[{"name":"option_value","type":"string","required":true,"description":"The option label."},{"name":"is_default","type":"boolean","required":true,"description":"REQUIRED \u2014 a missing value is a 400. Must be 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 parent option this hangs under."}],"intent":"Rename an option or change its default (POST to /update).","guidance":["NON-destructive, stored values follow the option id","Both option_value and is_default are required","a missing is_default is a 400","Renaming an option keeps the stored values pointing at it (the option id is unchanged)","so this is the NON-destructive way to fix a label"]},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"body":[{"name":"link_projects","type":"array","description":"Project INTEGER ids to ADD. Note the spelling \u2014 not `linked_projects`."},{"name":"unlink_projects","type":"array","description":"Project INTEGER ids to REMOVE. Destroys their stored values."},{"name":"link_to_future_projects","type":"boolean"},{"name":"select_all","type":"boolean","description":"Apply to every project; may switch the call to async."}],"intent":"Link or unlink projects for a dataset \u2014 how you change which projects see a field.","guidance":["CHANGE WHICH PROJECTS","Unlinking destroys that project's values","The keys here are link_projects and unlink_projects","Send the wrong spelling and it is silently dropped: 200, nothing changed","This is the single easiest mistake on this surface","These are DELTAS, not the complete new list: send only the projects to add and the projects to remove"]},{"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,"description":"Display label. Editable. REQUIRED even when unchanged."},{"name":"field_type","type":"string","required":true,"description":"REQUIRED for validation, but any change is silently ignored."},{"name":"is_required","type":"boolean","required":true,"description":"REQUIRED \u2014 omitting it is a 400."},{"name":"entity_type","type":"string"},{"name":"place_holder_text","type":"string"},{"name":"default_value","type":"string","description":"Only applied when `field_type` is `boolean`."}],"intent":"Update a field definition's label, requiredness or placeholder (POST to /update).","guidance":["Full replace: field_name + field_type + is_required all required","field_type changes are silently ignored","field_name, field_type and is_required must all be present","Omitting is_required is 400 \"Invalid Params\"","omitting field_name is a 500","field_name IS editable here"]},{"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","description":"Updated name of the dataset.","json_path":"/dataset/name"},{"name":"variables","type":"array","description":"Updated list of dataset variables/columns (max 40).","fields":[{"name":"name","type":"string","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/variables"},{"name":"rows","type":"array","description":"Updated list of dataset rows (max 100).","fields":[{"name":"row_number","type":"integer","required":true},{"name":"row_data","type":"array","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/rows"}],"intent":"Update a dataset.","guidance":["Update an existing dataset by its UUID with new variables and rows"],"returns":["name","created_at","rows_count","uuid"]},{"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 for the session.","json_path":"/exploratory_session/title"},{"name":"description","type":"string","description":"Updated description.","json_path":"/exploratory_session/description"},{"name":"charter","type":"string","description":"Updated testing charter.","json_path":"/exploratory_session/charter"},{"name":"timebox_duration","type":"integer","example":90,"description":"Updated planned duration in minutes.","json_path":"/exploratory_session/timebox_duration"},{"name":"duration","type":"integer","example":45,"description":"Tracked duration in minutes.","json_path":"/exploratory_session/duration"},{"name":"actual_duration","type":"integer","example":50,"description":"Final actual duration in minutes.","json_path":"/exploratory_session/actual_duration"},{"name":"folder_id","type":"integer","example":789,"description":"Move the session to a different folder.","json_path":"/exploratory_session/folder_id"},{"name":"owner","type":"integer","example":55,"description":"TCM user ID of the new owner / assignee.","json_path":"/exploratory_session/owner"},{"name":"assignee","type":"integer","example":55,"description":"Alias for `owner`.","json_path":"/exploratory_session/assignee"},{"name":"tags","type":"array","example":["smoke","payment"],"description":"Replace the session's tags with this list.","json_path":"/exploratory_session/tags"},{"name":"configurations","type":"array","example":[2,4],"description":"Replace the session's configuration IDs.","json_path":"/exploratory_session/configurations"},{"name":"attachments","type":"array","description":"Updated set of blob / media attachment IDs.","json_path":"/exploratory_session/attachments"},{"name":"issues","type":"array","description":"Replace the linked issues for this session.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/exploratory_session/issues"}],"intent":"Update an exploratory session","guidance":["edit a session (title, charter, timebox_duration, duration, owner, tags, ...)","Updates one or more fields of an exploratory session","Time fields (timebox_duration, duration, actual_duration) must be non-negative integers not exceeding 2147483647"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"]},{"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":"Updated rich-text HTML content of the log entry.","json_path":"/session_log/content"},{"name":"status","type":"string","values":["pass","fail","bug","retest","block","note"],"example":"fail","description":"Updated test result status.","json_path":"/session_log/status"},{"name":"elapsed_time","type":"integer","example":180,"description":"Updated elapsed time in seconds.","json_path":"/session_log/elapsed_time"},{"name":"defects","type":"array","description":"Replace the linked defects for this log entry.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/session_log/defects"}],"intent":"Update a session log entry","guidance":["Updates an existing log entry within an exploratory session","Rich-text HTML content is sanitised before storage"],"returns":["id","status","content","created_at","elapsed_time","session_id","updated_at"]},{"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,"description":"Name of the filter"},{"name":"entity","type":"string","required":true,"values":["testcase","testplan","testrun","exploratory_session","test_plan_test_case"],"description":"Entity type the filter applies to. The server's validator accepts five values (the\nlast two were missing here); an unlisted value returns 400 \"Invalid JSON data\"."},{"name":"is_project_level","type":"boolean","description":"Whether this filter is available at project level"},{"name":"filters","type":"object","description":"Filter criteria object containing the actual filter conditions"}],"intent":"Update a filter","guidance":["Update an existing saved filter with new configuration or filter criteria"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"]},{"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"},{"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 these are ids, not names).","json_path":"/test_result/status_id"},{"name":"description","type":"string","description":"Rich text description of the test result.","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's 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":"Update the latest test result","guidance":["update the LATEST result for a case in a run (partial, same body shape)","Update the most recent test result for a specific test case within a test run in a project","Optionally filter by configuration_id or mapping_id"],"returns":["id","name","byte_size","checksum","content_type","download_url","key","product_id","size","url"]},{"method":"POST","path":"/api/v1/projects/{project_id}/settings","mode":"write","entity":"project","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Update project settings","guidance":["Accepts an object with setting keys as properties and boolean values"]},{"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","example":10000},{"name":"report_type","type":"string","required":true,"values":["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","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_statu"},{"name":"title","type":"string","example":"Updated Title"},{"name":"description","type":"string","example":""},{"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 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`"},{"name":"report_creation_mode","type":"string","required":true,"values":["custom_report","system_generated"],"example":"custom_report"},{"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"},{"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":"Integer run ids \u2014 pairs with report_timeframe=specific_test_runs.","json_path":"/report_filters/test_runs"},{"name":"test_plans","type":"array","description":"Integer plan ids \u2014 pairs with report_timeframe=specific_test_plans.","json_path":"/report_filters/test_plans"},{"name":"test_cases","type":"string","description":"Case selection \u2014 pairs with report_timeframe=specific_test_cases. FOLDER-KEYED\n(see SelectionData), never a flat id list: the server resolves the selection\nthrough its folder map, so any other shape yields zero cases and saves an\nUNSCOPED, project-wide report at 200.\n\nSend all three keys. Folder ids are STRING keys; test-case ids are INTEGERS\n(the numeric `id`, not the TC-NNN identifier) \u2014 take bo","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":"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.","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 } }.","json_path":"/report_filters/test_plan_selection"},{"name":"builds","type":"array","json_path":"/report_filters/builds"},{"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\n`Test Run Summary`, `Test Run Detailed Report` and `Test Case Activity` \u2014 on\nthe other six report types the server never reads it and drops it silently.\nPersisted (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`."},{"name":"users","type":"array","json_path":"/mail_to/users"},{"name":"external_mails","type":"array","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\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"},{"name":"time","type":"string","example":"08:00","description":"24-hour HH:MM, UTC.","json_path":"/frequency_details/time"},{"name":"day","type":"string","example":"monday","description":"Required for weekly (lowercase day name) and monthly (integer 1-28).\nOmit for daily.","json_path":"/frequency_details/day"}],"intent":"Update a scheduled report","guidance":["FULL REPLACE, not a delta: omitted fields are nulled and dropping frequency cancels the schedule","Update the configuration of an existing scheduled report for a project"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"]},{"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":"Title of the shared step"},{"name":"folder_id","type":"integer","description":"ID of the shared-field folder to move the shared step into. Null moves it to the root (Unassigned)."},{"name":"shared_step_details","type":"array","required":true,"fields":[{"name":"id","type":"integer"},{"name":"step","type":"string"},{"name":"result","type":"string"},{"name":"order","type":"integer"},{"name":"test_data","type":"string"}]}],"intent":"Update a shared step","guidance":["update a shared step","title and shared_step_details are BOTH required, and the details array REPLACES the existing steps","Updates an existing shared step in a project"],"returns":["id","title"]},{"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"},{"name":"description","type":"string","json_path":"/test_plan/description"},{"name":"start_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/start_date"},{"name":"end_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/end_date"},{"name":"plan_status","type":"string","description":"Plan status. The list filter accepts new / started / completed.","json_path":"/test_plan/plan_status"},{"name":"owner","type":"integer","description":"Owner user id \u2014 resolve via the project users read first.","json_path":"/test_plan/owner"},{"name":"parent_plan_id","type":"integer","description":"Parent plan's INTEGER id. Set it to create (or re-parent into) a SUB-plan \u2014 the\nresult carries an STP-NNN identifier. Null/omitted means a top-level plan.","json_path":"/test_plan/parent_plan_id"},{"name":"tags","type":"array","json_path":"/test_plan/tags"},{"name":"reviewers","type":"array","description":"Reviewer user ids.","json_path":"/test_plan/reviewers"},{"name":"attachments","type":"array","description":"Attachment ids already uploaded via the attachments surface.","json_path":"/test_plan/attachments"},{"name":"custom_fields","type":"object","json_path":"/test_plan/custom_fields"},{"name":"issues","type":"array","description":"Linked tracker issues. Structured \u2014 NOT a flat array of keys.","fields":[{"name":"issue_id","type":"string"},{"name":"issue_type","type":"string"},{"name":"metadata","type":"object"}],"json_path":"/test_plan/issues"},{"name":"test_runs","type":"string","description":"The plan's linked test runs. Accepts EITHER an array of test-run integer ids, OR a\nbulk-selection object for \"every run matching this filter\". On update this REPLACES\nthe existing link set rather than appending.","json_path":"/test_plan/test_runs"}],"intent":"Update a test plan (or sub-plan)","guidance":["Update a plan by its INTEGER id","Takes the same body as create","so it also works on a sub-plan by its own id","clearing it promotes a sub-plan to a top-level plan","do that only when the user actually asked to move it in the hierarchy","Send only the fields you intend to change"]},{"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 AI-generated or AI-processed attachments","guidance":["upload a file as AI CONTEXT (restricted MIME types, smaller size cap) to seed test-case content","Files are stored in S3 with pre-signed URLs","File Storage: Files uploaded to S3 with pre-signed URLs (expires in ~26 minutes)"],"returns":["id","name","content_type","download_url","size","url"]},{"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 generic attachments to a project from rich text editor or other sources","guidance":["UPLOAD file blob(s) (multipart)","Uploads one or more files as generic attachments to a project","Files are stored in S3 and pre-signed URLs are returned for both viewing and downloading"],"returns":["id","name","content_type","download_url","size","url"]},{"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":"Upload one or more attachments to a test case","guidance":["Upload one or more attachments to a specific test case within a folder in a project"],"returns":["id","byte_size","content_type","filename"]},{"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 whose per-test-case / per-run / per-day breakdown is requested."}],"intent":"Fetch the User Workload drill-down for a single user","guidance":["User-Workload per-user execution breakdown","Only valid on a report whose type is User Workload Report","every other type returns 400 \"Drill-down is only available for User Workload Reports\""],"shape":"discovered","paginated":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},"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)","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_v1"]},"attachment":{"entity":"attachment","title":"Attachments","aliases":["attachment","file","attachments","blob"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"attached to"},{"entity":"result","via":"attached to"}],"capabilities":["delete_test_case_attachment","get_test_case_attachments_v1","upload_a_i_attachments","upload_generic_attachments","upload_test_case_attachments_v1"]},"configuration":{"entity":"configuration","title":"Configurations","aliases":["config","configuration","environment","browser","os","device"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_run","via":"case status tracked against"}],"capabilities":["create_configuration_v1","get_configurations_v1"]},"custom_field":{"entity":"custom_field","title":"Custom fields & system fields","aliases":["custom field","custom fields","field","system field"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"extends"},{"entity":"result","via":"extends"}],"capabilities":["create_custom_field_dataset_option_v2","create_custom_field_dataset_v2","create_custom_field_definition_v2","delete_custom_field_dataset_option_v2","delete_custom_field_dataset_v2","delete_custom_field_definition_v2","get_custom_field_dataset_v2","get_custom_field_definition_v2","get_custom_field_values_v1","get_project_id_custom_fields_v2_v1","get_test_results_custom_fields_v1","list_custom_field_dataset_options_v2","list_workspace_fields_v2","update_custom_field_dataset_option_v2","update_custom_field_dataset_project_mapping_v2","update_custom_field_definition_v2"]},"dataset":{"entity":"dataset","title":"Dataset","aliases":["datasets","data-driven-data","dds"],"id_convention":"uuid","parents":["project"],"relations":[{"entity":"test_case","via":"drives"},{"entity":"variable","via":"has columns"}],"capabilities":["create_dataset","delete_dataset","get_dataset","get_dataset_linked_test_cases","get_dataset_variables","import_dataset_c_s_v","list_datasets","update_dataset"]},"duplicate":{"entity":"duplicate","title":"Duplicate recommendation (AI dedupe)","aliases":["duplicate","duplicates","dedupe","deduplication","duplicate recommendation","ai duplicates","redundant test cases"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"links the pair it believes are duplicates"}],"capabilities":["discard_duplicate_v1","get_dedupe_status_v1","get_duplicate_source_test_case_v1","get_duplicate_v1","list_duplicates_v1","resolve_duplicate_v1","search_duplicates_v1"]},"exploratory_session":{"entity":"exploratory_session","title":"Exploratory session","aliases":["session","exploratory","et-session"],"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_v1","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"]},"filter":{"entity":"filter","title":"Saved filter view","aliases":["filter","filters","saved view","saved 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"]},"folder":{"entity":"folder","title":"Folder","aliases":["dir","directory"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"folder","via":"nests under"},{"entity":"test_case","via":"organises"}],"capabilities":["copy_folder","create_bulk_test_cases_v1","create_root_folder_v1","create_sub_folder_v1","delete_folder_and_contents","get_folder_remove_summary","get_folder_tree_v1","get_root_folders_v1","list_folder_contents","move_folder_v1","rename_folder_v1","reorder_folders","undo_remove_folder"]},"project":{"entity":"project","title":"Project","aliases":["pr","workspace"],"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_v1","export_dashboard_analytics","get_active_test_runs_info_v1","get_automation_stats_v1","get_closed_test_runs_info_v1","get_closed_test_runs_split_v1","get_entity_filter_details_v1","get_issues_count_info_v1","get_project_by_integer_id_v1","get_project_settings","get_project_users_v2_v1","get_projects_basic_v1","get_projects_minify_v1","get_test_case_count_trend_v1","get_test_case_type_split_v1","list_projects_v1","search_project_entities","update_project_settings"]},"report":{"entity":"report","title":"Report","aliases":["reports","scheduled-report","schedule","analytics-report"],"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","delete_report_schedule","download_report","get_exploratory_session_summary_v1","get_report_attachment_url","get_report_detail","get_report_section_v1","get_report_summary_by_type","get_selected_report_testcases","list_schedules","send_report_email_now_v1","update_report","user_workload_drilldown"]},"result":{"entity":"result","title":"Result","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_v1","create_step_result_v1","create_test_result_for_test_case","delete_test_result_v1","get_test_results_for_test_case","update_latest_test_result_for_test_case"]},"shared_step":{"entity":"shared_step","title":"Shared step","aliases":["shared step","shared steps","reusable step","step template"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"reused by"}],"capabilities":["create_shared_step_v1","delete_shared_step_v1","get_shared_steps_by_i_d_v1","get_shared_steps_v1","update_shared_step_v1"]},"tag":{"entity":"tag","title":"Tag","aliases":["tags","label","labels"],"id_convention":"name","parents":["project"],"relations":[{"entity":"test_case","via":"labels"},{"entity":"test_run","via":"labels"},{"entity":"test_plan","via":"labels"}],"capabilities":["bulk_edit_test_cases_v2","get_test_case_tags","list_test_case_tags_v1","merge_tags_v1","search_group_tags","search_test_case_tags"]},"test_case":{"entity":"test_case","title":"Test case","aliases":["tc","case","test case"],"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":["bulk_archive_test_cases_by_project","bulk_copy_test_cases","bulk_delete_test_cases","bulk_edit_test_cases","bulk_edit_test_cases_in_test_run","bulk_move_test_cases","bulk_retrieve_test_cases","create_test_case_v1","create_test_cases","edit_test_case_partial_v1","edit_test_case_v1","get_archived_test_cases","get_system_field_values_v1","get_test_case_by_integer_id_v1","get_test_case_detail","get_test_cases_v1","list_folder_test_cases_v1","reorder_test_cases_by_folder_v1","search_project_entities_v2","search_test_cases_by_folder_v1"]},"test_plan":{"entity":"test_plan","title":"Test plan (and sub-plan)","aliases":["tp","plan","milestone","sub test plan","subplan","stp"],"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_v1","bulk_retrieve_test_plans_v1","clone_test_plan_v1","count_test_plan_test_runs_v1","create_test_plan_v1","delete_test_plan_v1","get_archived_test_plan_count_v1","get_test_plan_by_integer_id_v1","get_test_plan_execution_trend_v1","get_test_plan_linked_sessions_chart_data_v1","get_test_plan_test_runs_v1","list_archived_test_plans_v1","list_test_plans_v1","update_test_plan_v1"]},"test_run":{"entity":"test_run","title":"Test run","aliases":["tr","run","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":["assign_test_run_owner","bulk_assign_test_cases_to_test_run","clone_test_run","close_test_run_v1","count_run_selection_v1","create_test_run_v1","delete_v1_test_run","edit_test_run","get_closed_test_runs","get_test_cases_for_v1_test_run","get_test_run_by_integer_id_v1","get_test_run_cases_v1","get_test_runs_form_fields_v1","get_test_runs_selection","get_test_runs_v1"]},"user":{"entity":"user","title":"User","aliases":["users","member","assignee","owner"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"project","via":"member of"},{"entity":"test_run","via":"assigned to"},{"entity":"test_case","via":"owns"}],"capabilities":["get_group_users_v2_v1","search_project_users"]},"version":{"entity":"version","title":"Test case version","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":["get_test_case_histories_v1","get_test_case_history_diff_v1","get_test_case_history_v1","restore_history_v1"]}}}}} \ No newline at end of file diff --git a/tests/tools/capabilityRegistry.test.ts b/tests/tools/capabilityRegistry.test.ts index c4640c8..3afa6af 100644 --- a/tests/tools/capabilityRegistry.test.ts +++ b/tests/tools/capabilityRegistry.test.ts @@ -238,7 +238,10 @@ describe("invoke", () => { }; const result = await invoke( LIST_CASES, { path_params: { project_id: 1, folder_id: 2 } }, - "https://tm.example", credentials, transport, + "https://tm.example", credentials, transport, {}, + // The ceiling is NOT on the capability: it is a resolver control, so it travels in the + // artifact's sibling `paging` map and is passed in here. + { page: "p", size: "count", max: 300 }, ); expect(result.ok).toBe(true); expect(result.count).toBe(880); @@ -252,7 +255,7 @@ describe("invoke", () => { const transport = async () => ({ status: 200, body: { rows: [{ unrelated: 1 }] } }); const result = await invoke( LIST_CASES, { path_params: { project_id: 1, folder_id: 2 } }, - "https://tm.example", credentials, transport, + "https://tm.example", credentials, transport, {}, { page: "p", size: "count", max: 300 }, ); expect(result.ok).toBe(false); expect(result.error).toMatch(/capability_returns_drift/); @@ -268,3 +271,24 @@ describe("invoke", () => { expect(result.error).toMatch(/401/); }); }); + + +describe("paging controls stay out of the published surface", () => { + it("is read from the artifact's sibling map, not from the capability", () => { + const registry = new CapabilityRegistry({ + ...INDEX, + products: { + tm: { + ...INDEX.products.tm, + paging: { [`GET ${LIST_CASES.path}`]: { page: "p", size: "count", max: 300 } }, + }, + }, + }); + expect(registry.pagingFor("tm", LIST_CASES)).toEqual({ page: "p", size: "count", max: 300 }); + // an endpoint that does not page gets an empty rule rather than a guess + expect(registry.pagingFor("tm", CREATE_FOLDER)).toEqual({}); + // and none of it is visible to a caller searching + const hit = searchCapabilities(registry.index.products, "test cases").capabilities[0]; + expect(JSON.stringify(hit)).not.toContain('"page"'); + }); +}); diff --git a/tests/tools/capabilityRegistryE2E.test.ts b/tests/tools/capabilityRegistryE2E.test.ts new file mode 100644 index 0000000..3d3cfaf --- /dev/null +++ b/tests/tools/capabilityRegistryE2E.test.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fileURLToPath } from "node:url"; + +const FIXTURE = fileURLToPath(new URL("../fixtures/registry-index.json", import.meta.url)); + +const CONFIG = { + "browserstack-username": "ing_Xx", + "browserstack-access-key": "SECRET", +} as any; + +async function buildServer() { + // Imported lazily so the env below is in place before config.ts resolves the artifact. + const { BrowserStackMcpServer } = await import("../../src/server-factory.js"); + return new BrowserStackMcpServer(CONFIG); +} + +describe("capability registry, end to end through the server factory", () => { + beforeEach(() => { + process.env.CAPABILITY_REGISTRY_INDEX = FIXTURE; + delete process.env.CAPABILITY_REGISTRY_DISABLED; + vi.resetModules(); + }); + + afterEach(() => { + delete process.env.CAPABILITY_REGISTRY_INDEX; + delete process.env.CAPABILITY_REGISTRY_BASE_URL_TM; + vi.unstubAllGlobals(); + }); + + it("registers its five tools alongside the hand-written ones", async () => { + const server = await buildServer(); + const tools = server.getTools(); + for (const name of ["listProducts", "listEntities", "describeEntity", + "searchCapability", "invokeEndpoint"]) { + expect(tools[name], name).toBeDefined(); + } + // the existing surface is untouched + expect(tools.listTestCases ?? tools.createTestCase).toBeDefined(); + }); + + it("registers nothing, and does not throw, when the artifact is missing", async () => { + process.env.CAPABILITY_REGISTRY_INDEX = "/nonexistent/index.json"; + const server = await buildServer(); + // A packaging problem must not take every other product's tools down with it. + expect(server.getTools().invokeEndpoint).toBeUndefined(); + expect(Object.keys(server.getTools()).length).toBeGreaterThan(5); + }); + + it("honours the kill switch", async () => { + process.env.CAPABILITY_REGISTRY_DISABLED = "true"; + const server = await buildServer(); + expect(server.getTools().searchCapability).toBeUndefined(); + }); + + it("searches the real index through the registered tool", async () => { + const server = await buildServer(); + const result: any = await (server.getTools().searchCapability as any).handler( + { query: "list the test cases in a folder" }, {} as any, + ); + const payload = JSON.parse(result.content[0].text); + expect(payload.build_id).toMatch(/caps/); + expect(payload.capabilities.length).toBeGreaterThan(0); + expect(payload.capabilities[0].path.startsWith("/api/")).toBe(true); + }); + + it("invokes a real endpoint: forwards Api-Token, pages, and projects the rows", async () => { + process.env.CAPABILITY_REGISTRY_BASE_URL_TM = "https://tm.example"; + const calls: { url: string; headers: Record }[] = []; + vi.stubGlobal("fetch", async (url: string, init: any) => { + calls.push({ url: String(url), headers: init.headers }); + return { + status: 200, + headers: { get: () => "application/json" }, + json: async () => ({ + projects: [{ id: 1, name: "P", description: "d", leaked: "no" }], + info: { count: 1 }, + }), + }; + }); + + const server = await buildServer(); + const result: any = await (server.getTools().invokeEndpoint as any).handler( + { method: "GET", path: "/api/v1/projects/basic" }, {} as any, + ); + const payload = JSON.parse(result.content[0].text); + + expect(payload.ok).toBe(true); + expect(calls[0].headers["Api-Token"]).toBe("ing_Xx:SECRET"); + expect(calls[0].headers["request-source"]).toBe("ai-chatbot"); + expect(calls[0].url.startsWith("https://tm.example/api/v1/projects/basic")).toBe(true); + // the page-size ceiling the operation declares, not the product's default + expect(calls[0].url).toContain("count=300"); + // projected to the declared returns: the extra field never reaches the caller + expect(payload.items[0].leaked).toBeUndefined(); + expect(payload.items[0].id).toBe(1); + }); + + it("refuses a destructive endpoint without calling the product", async () => { + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + const server = await buildServer(); + const result: any = await (server.getTools().invokeEndpoint as any).handler( + { + method: "POST", + path: "/api/v1/projects/{project_id}/test-plans/{test_plan_id}/delete", + path_params: { project_id: 1, test_plan_id: 2 }, + user_permission: "granted", + change_summary: "delete it", + }, + {} as any, + ); + expect(result.isError).toBe(true); + expect(JSON.parse(result.content[0].text).error).toMatch(/destructive/); + expect(fetchSpy).not.toHaveBeenCalled(); // refused before any egress + }); + + it("refuses a write until the user has confirmed, and validates params first", async () => { + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + const server = await buildServer(); + const invokeEndpoint = server.getTools().invokeEndpoint as any; + + const noConsent: any = await invokeEndpoint.handler( + { method: "POST", path: "/api/v1/projects/{project_id}/folders", + path_params: { project_id: 1 }, body: { name: "New" } }, {} as any, + ); + expect(JSON.parse(noConsent.content[0].text).error).toMatch(/ask the user to confirm/); + + // A typo must surface as a parameter error, NOT as "go ask a human" about a call that + // was never going to run. + const typo: any = await invokeEndpoint.handler( + { method: "POST", path: "/api/v1/projects/{project_id}/folders", + path_params: { project_id: 1 }, body: { nmae: "New" } }, {} as any, + ); + expect(JSON.parse(typo.content[0].text).error).toMatch(/unknown body: nmae/); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); From 034b2c2ba08bf6e316cfc9dfb7d4edb88ba83dc6 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Wed, 19 Aug 2026 16:49:26 +0530 Subject: [PATCH 03/31] Resolve base_url the way Atlas does, and stop hardcoding the TM host MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Precedence: config override, then the harness-declared host from the artifact, then product-specific discovery, then refuse. FIXES A BUG I WOULD HAVE SHIPPED. The first version hardcoded test-management.browserstack.com for tm and invented hosts for a11y and tra from a naming pattern. But the package already resolves TM's host per ACCOUNT: getTMBaseURL probes test-management{,-eu,-in}.browserstack.com with the caller's credentials, caching per process in stdio mode and deliberately never in REMOTE_MCP mode so one tenant's region is not served to another. A fixed host would have failed every EU and IN account on every call — and that is the live path behind the "Unable to connect to Test Management" error users already see. So tm now defers to getTMBaseURL, and a11y/tra are NOT guessed: an unknown host is refused by name. A guessed host fails as a DNS error or a 404 that reads like the caller's problem, when it is our missing configuration. Add a product here only once its host is known rather than inferred. The sharp edge is documented where it bites: a harness-declared host is one fixed origin, so declaring one for a region-sharded product would send EU and IN accounts to the wrong region. tm declares none for exactly that reason and falls through to discovery. Auth stays hardcoded to `Api-Token: :` by decision — it is a BrowserStack-wide convention rather than a per-product quirk, and the artifact therefore carries no auth information at all. The harness PR that declares the scheme is consequently NOT a dependency of this half; it matters for the Python registry, whose auth resolution is declaration-driven, and for the spec telling the truth. Tests mock the resolver module (the probe is axios-based, so a fetch stub cannot reach it) and assert each rung of the precedence, including that a request lands on the EU host when that is the account's region. Full suite: 325 tests across 33 files, lint and tsc clean. --- src/tools/capability-registry/config.ts | 59 +++++++--- src/tools/capability-registry/register.ts | 14 ++- src/tools/capability-registry/types.ts | 6 ++ .../tools/capabilityRegistryArtifact.test.ts | 7 +- tests/tools/capabilityRegistryRegion.test.ts | 101 ++++++++++++++++++ 5 files changed, 169 insertions(+), 18 deletions(-) create mode 100644 tests/tools/capabilityRegistryRegion.test.ts diff --git a/src/tools/capability-registry/config.ts b/src/tools/capability-registry/config.ts index 2f3cd18..661ce2a 100644 --- a/src/tools/capability-registry/config.ts +++ b/src/tools/capability-registry/config.ts @@ -1,24 +1,59 @@ /** * Where the index comes from, and where each product lives. * - * `base_url` is deliberately NOT in the artifact — it is environment-specific, so the same - * artifact ships everywhere and the host is resolved here instead. + * `base_url` is deliberately NOT in the artifact — it is environment- AND account-specific, + * so the same artifact ships everywhere and the host is resolved here. */ import { existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join, resolve } from "node:path"; -const PROD_HOSTS: Record = { - tm: "https://test-management.browserstack.com", - a11y: "https://accessibility.browserstack.com", - tra: "https://test-reporting.browserstack.com", -}; +import { BrowserStackConfig } from "../../lib/types.js"; +import { getTMBaseURL } from "../../lib/tm-base-url.js"; +import { InvocationError } from "./index-loader.js"; -/** Per-product override, e.g. CAPABILITY_REGISTRY_BASE_URL_TM=https://…-preprod.bsstag.com */ -export function baseUrlFor(product: string): string { +/** + * Resolve a product's host. + * + * tm is REGION-SPECIFIC and the package already discovers it: `getTMBaseURL` probes + * test-management{,-eu,-in}.browserstack.com with the caller's credentials and returns the + * one their account lives on. Hardcoding the default host instead would fail every EU and + * IN account on every call, which is what an earlier version of this file did. + * + * An explicit override still wins, because that is how a non-production environment is + * reached — no preprod host is or should be compiled in. + * + * Other products are NOT guessed. A wrong host fails as a DNS error or a 404 that reads + * like the caller's problem; refusing names the real cause. Add one here only once its host + * is known rather than inferred from a naming pattern. + */ +export async function resolveBaseUrl( + product: string, + config: BrowserStackConfig, + harnessBaseUrl?: string, +): Promise { + // 1. CONFIG WINS, the same precedence Atlas uses. This is how a non-production + // environment is reached; no preprod host is or should be compiled in. const override = process.env[`CAPABILITY_REGISTRY_BASE_URL_${product.toUpperCase()}`]; - return (override || PROD_HOSTS[product] || "").replace(/\/$/, ""); + if (override) return override.replace(/\/$/, ""); + + // 2. Then whatever the HARNESS declared, carried through in the artifact. + // NOTE the sharp edge: a harness-declared host is one fixed origin, so declaring one + // for a region-sharded product would send EU and IN accounts to the wrong region. + // tm therefore declares none on purpose and falls through to discovery below. + if (harnessBaseUrl) return harnessBaseUrl.replace(/\/$/, ""); + + // 3. Product-specific discovery. tm is region-sharded and the package already probes + // test-management{,-eu,-in} with the caller's credentials to find their account's. + if (product === "tm") return (await getTMBaseURL(config)).replace(/\/$/, ""); + + // 4. Refuse rather than guess. A guessed host fails as a DNS error or a 404 that reads + // like the caller's problem, when it is our missing configuration. + throw new InvocationError( + `no host is configured for product '${product}': the harness declares none and there ` + + `is no override. Set CAPABILITY_REGISTRY_BASE_URL_${product.toUpperCase()}.`, + ); } /** @@ -32,8 +67,8 @@ export function indexPath(): string | undefined { const here = dirname(fileURLToPath(import.meta.url)); const candidates = [ join(here, "registry-index.json"), - join(here, "..", "..", "..", "capability-index.json"), // dist/ -> package root - join(here, "..", "..", "..", "..", "capability-index.json"), // src/ -> package root + join(here, "..", "..", "..", "capability-index.json"), // dist/ or src/ -> package root + join(here, "..", "..", "..", "..", "capability-index.json"), ]; return candidates.find((candidate) => existsSync(candidate)); } diff --git a/src/tools/capability-registry/register.ts b/src/tools/capability-registry/register.ts index 7508ec2..69be127 100644 --- a/src/tools/capability-registry/register.ts +++ b/src/tools/capability-registry/register.ts @@ -16,7 +16,7 @@ import logger from "../../logger.js"; import { trackMCP } from "../../lib/instrumentation.js"; import { BrowserStackConfig } from "../../lib/types.js"; import { GroupedArguments } from "./bind.js"; -import { baseUrlFor, indexPath, isEnabled } from "./config.js"; +import { indexPath, isEnabled, resolveBaseUrl } from "./config.js"; import { Credentials, Transport, fetchTransport } from "./egress.js"; import { CapabilityRegistry, InvocationError } from "./index-loader.js"; import { invoke } from "./resolve.js"; @@ -27,8 +27,11 @@ export const PERMISSION_VALUES = ["not_asked", "granted", "denied"] as const; export interface RegistryDeps { registry: CapabilityRegistry; - /** Per-product base URL. Never baked into the artifact — it is environment-specific. */ - baseUrlFor: (product: string) => string; + /** + * 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; credentialsFor: () => Credentials; transport?: Transport; } @@ -72,7 +75,8 @@ export function addCapabilityRegistryToolsFromConfig( ); return addCapabilityRegistryTools(server, { registry, - baseUrlFor, + baseUrlFor: (product) => + resolveBaseUrl(product, config, registry.index.products[product]?.base_url), // Read per call, not captured: the remote server rebuilds config per session, so a // captured credential would outlive the session it belongs to. credentialsFor: () => ({ @@ -251,7 +255,7 @@ export function addCapabilityRegistryTools( } const result = await invoke( - capability, args, deps.baseUrlFor(product), deps.credentialsFor(), transport, + capability, args, await deps.baseUrlFor(product), deps.credentialsFor(), transport, { orderBy: input.order_by, topN: input.top_n }, registry.pagingFor(product, capability), ); diff --git a/src/tools/capability-registry/types.ts b/src/tools/capability-registry/types.ts index e082eed..5cc0731 100644 --- a/src/tools/capability-registry/types.ts +++ b/src/tools/capability-registry/types.ts @@ -80,6 +80,12 @@ export interface PagingRule { export interface ProductIndex { summary: string; + /** + * The host the HARNESS declares for this product, if any. Config overrides it — the same + * precedence Atlas uses. Absent for tm, whose product.yaml deliberately leaves the host + * to config so that per-account region sharding can be honoured. + */ + base_url?: string; capabilities: Capability[]; entities: Record; paging?: Record; diff --git a/tests/tools/capabilityRegistryArtifact.test.ts b/tests/tools/capabilityRegistryArtifact.test.ts index 8113a74..b930202 100644 --- a/tests/tools/capabilityRegistryArtifact.test.ts +++ b/tests/tools/capabilityRegistryArtifact.test.ts @@ -23,7 +23,12 @@ describe("the real artifact", () => { } }); - it("bakes in no hostname, so one artifact ships to every environment", () => { + it("carries only a harness-declared host, and tm declares none", () => { + // Harness declares the default, config overrides it — the same precedence Atlas uses. + // What must never appear is a host that came from CONFIG, since one artifact ships to + // every environment. tm's product.yaml leaves the host to config on purpose, so that + // per-account region sharding is honoured. + expect(registry.index.products.tm.base_url).toBeUndefined(); const blob = JSON.stringify(registry.index); for (const host of ["bsstag.com", "browserstack.com", "https://"]) { expect(blob).not.toContain(host); diff --git a/tests/tools/capabilityRegistryRegion.test.ts b/tests/tools/capabilityRegistryRegion.test.ts new file mode 100644 index 0000000..6093654 --- /dev/null +++ b/tests/tools/capabilityRegistryRegion.test.ts @@ -0,0 +1,101 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { fileURLToPath } from "node:url"; + +// The package discovers which region an account's Test Management lives on. Hardcoding the +// default host would fail every EU and IN account on every call, so this asserts the +// registry defers to that resolver rather than to a constant of its own. +vi.mock("../../src/lib/tm-base-url.js", () => ({ + getTMBaseURL: vi.fn(async () => "https://test-management-eu.browserstack.com"), +})); + +const FIXTURE = fileURLToPath(new URL("../fixtures/registry-index.json", import.meta.url)); +const CONFIG = { + "browserstack-username": "ing_Xx", + "browserstack-access-key": "SECRET", +} as any; + +describe("base URL resolution", () => { + beforeEach(() => { + process.env.CAPABILITY_REGISTRY_INDEX = FIXTURE; + delete process.env.CAPABILITY_REGISTRY_BASE_URL_TM; + vi.resetModules(); + }); + + afterEach(() => { + delete process.env.CAPABILITY_REGISTRY_INDEX; + delete process.env.CAPABILITY_REGISTRY_BASE_URL_TM; + vi.unstubAllGlobals(); + }); + + it("sends the request to the region the account actually lives on", async () => { + const calls: string[] = []; + vi.stubGlobal("fetch", async (url: string) => { + calls.push(String(url)); + return { + status: 200, + headers: { get: () => "application/json" }, + json: async () => ({ projects: [{ id: 1, name: "P" }], info: { count: 1 } }), + }; + }); + + const { BrowserStackMcpServer } = await import("../../src/server-factory.js"); + const server = new BrowserStackMcpServer(CONFIG); + const result: any = await (server.getTools().invokeEndpoint as any).handler( + { method: "GET", path: "/api/v1/projects/basic" }, {} as any, + ); + + expect(JSON.parse(result.content[0].text).ok).toBe(true); + expect(calls[0].startsWith("https://test-management-eu.browserstack.com/")).toBe(true); + }); + + it("an explicit override still wins, which is how a non-prod environment is reached", async () => { + process.env.CAPABILITY_REGISTRY_BASE_URL_TM = "https://tm-preprod.example"; + const calls: string[] = []; + vi.stubGlobal("fetch", async (url: string) => { + calls.push(String(url)); + return { + status: 200, + headers: { get: () => "application/json" }, + json: async () => ({ projects: [], info: { count: 0 } }), + }; + }); + + const { BrowserStackMcpServer } = await import("../../src/server-factory.js"); + const server = new BrowserStackMcpServer(CONFIG); + await (server.getTools().invokeEndpoint as any).handler( + { method: "GET", path: "/api/v1/projects/basic" }, {} as any, + ); + expect(calls[0].startsWith("https://tm-preprod.example/")).toBe(true); + }); + + it("refuses a product whose host is unknown instead of guessing one", async () => { + // A guessed host fails as a DNS error or a 404 that reads like the caller's problem. + const { resolveBaseUrl } = await import("../../src/tools/capability-registry/config.js"); + await expect(resolveBaseUrl("a11y", CONFIG)).rejects.toThrow(/no host is configured/); + }); +}); + +describe("harness declares, config overrides — the same precedence Atlas uses", () => { + const HARNESS_HOST = "https://tm.harness-declared.example"; + + it("uses the harness-declared host when config says nothing", async () => { + delete process.env.CAPABILITY_REGISTRY_BASE_URL_TM; + const { resolveBaseUrl } = await import("../../src/tools/capability-registry/config.js"); + expect(await resolveBaseUrl("tm", CONFIG, `${HARNESS_HOST}/`)).toBe(HARNESS_HOST); + }); + + it("lets config override the harness", async () => { + process.env.CAPABILITY_REGISTRY_BASE_URL_TM = "https://tm-preprod.example"; + const { resolveBaseUrl } = await import("../../src/tools/capability-registry/config.js"); + expect(await resolveBaseUrl("tm", CONFIG, HARNESS_HOST)).toBe("https://tm-preprod.example"); + }); + + it("falls through to region discovery when the harness declares nothing", async () => { + // Which is tm's actual situation: its product.yaml leaves the host to config precisely + // so that per-account region sharding is honoured. + delete process.env.CAPABILITY_REGISTRY_BASE_URL_TM; + const { resolveBaseUrl } = await import("../../src/tools/capability-registry/config.js"); + expect(await resolveBaseUrl("tm", CONFIG, undefined)) + .toBe("https://test-management-eu.browserstack.com"); + }); +}); From 000229f47e1c906e28ffc0c4875d9a91bcdef843 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Wed, 19 Aug 2026 16:52:26 +0530 Subject: [PATCH 04/31] Support multiple environments for base_url, mirroring Atlas MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Atlas resolves a product's host in three config rungs — an explicit per-session override, then the host for the session's environment (harness.extra_environments[env][product]), then the profile's own base_url — and all three live in config, not in the harness bundle. This half had only the first. It now has the same ladder: 1. CAPABILITY_REGISTRY_BASE_URL_ explicit, environment-agnostic 2. CAPABILITY_REGISTRY_BASE_URL__ this environment's host 3. CAPABILITY_REGISTRY_BASE_URLS {product:{env:url}} the same as one map, the closest analogue of extra_environments 4. the harness-declared host from the artifact 5. product-specific discovery (tm is region-sharded) 6. refuse, by name ENVIRONMENT AND REGION ARE DIFFERENT THINGS, and the code says so where it matters. An environment is a property of the DEPLOYMENT (this instance talks to preprod), so it is read once from the process. A region is a property of the ACCOUNT (this user's data is in EU), which is why region discovery runs per request and is never cached under REMOTE_MCP. Conflating them would either send everyone to one region or re-probe on every call. Two refusals rather than fallbacks, both because the silent alternative is worse than an error: a named environment with no host defined does NOT fall through to the harness default (that would point a preprod deployment at production), and a malformed CAPABILITY_REGISTRY_BASE_URLS is rejected rather than read as "no override" (same outcome, arrived at by typo). Full suite: 331 tests across 33 files, lint and tsc clean. --- src/tools/capability-registry/config.ts | 85 +++++++++++++++++--- tests/tools/capabilityRegistryRegion.test.ts | 60 ++++++++++++++ 2 files changed, 132 insertions(+), 13 deletions(-) diff --git a/src/tools/capability-registry/config.ts b/src/tools/capability-registry/config.ts index 661ce2a..fd47404 100644 --- a/src/tools/capability-registry/config.ts +++ b/src/tools/capability-registry/config.ts @@ -28,31 +28,90 @@ import { InvocationError } from "./index-loader.js"; * like the caller's problem; refusing names the real cause. Add one here only once its host * is known rather than inferred from a naming pattern. */ +/** + * The environment this DEPLOYMENT points at, e.g. "preprod". + * + * Process-level on purpose, and the distinction from region matters: an environment is a + * property of the deployment (this instance talks to preprod), whereas a REGION is a + * property of the account (this user's data lives in EU). That is why region discovery is + * per request and never cached under REMOTE_MCP, while the environment is read once here. + */ +export function selectedEnvironment(): string { + return (process.env.CAPABILITY_REGISTRY_ENV || "").trim(); +} + +/** product -> env -> host, the analogue of Atlas's `harness.extra_environments`. */ +function environmentMap(): Record> { + const raw = process.env.CAPABILITY_REGISTRY_BASE_URLS; + if (!raw) return {}; + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" ? parsed : {}; + } catch { + // A malformed map must not silently mean "no override" — that would send a preprod + // deployment at production. + throw new InvocationError( + "CAPABILITY_REGISTRY_BASE_URLS is not valid JSON; expected {product: {env: url}}", + ); + } +} + +/** + * Resolve a product's host, mirroring Atlas's precedence. + * + * Atlas resolves: an explicit per-session override, then the host for the session's + * environment (`harness.extra_environments[env][product]`), then the profile's own + * `base_url`. The same rungs, in the same order: + * + * 1. CAPABILITY_REGISTRY_BASE_URL_ explicit, environment-agnostic + * 2. CAPABILITY_REGISTRY_BASE_URL__ this environment's host + * 3. CAPABILITY_REGISTRY_BASE_URLS {product:{env:url}} the same, as one map + * 4. the harness-declared host, carried in the artifact + * 5. product-specific discovery (tm is region-sharded) + * 6. refuse, by name + * + * 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. + */ export async function resolveBaseUrl( product: string, config: BrowserStackConfig, harnessBaseUrl?: string, ): Promise { - // 1. CONFIG WINS, the same precedence Atlas uses. This is how a non-production - // environment is reached; no preprod host is or should be compiled in. - const override = process.env[`CAPABILITY_REGISTRY_BASE_URL_${product.toUpperCase()}`]; - if (override) return override.replace(/\/$/, ""); + const key = product.toUpperCase(); + const environment = selectedEnvironment(); + + const explicit = process.env[`CAPABILITY_REGISTRY_BASE_URL_${key}`]; + if (explicit) return explicit.replace(/\/$/, ""); + + if (environment) { + const suffixed = process.env[ + `CAPABILITY_REGISTRY_BASE_URL_${key}_${environment.toUpperCase()}` + ]; + if (suffixed) return suffixed.replace(/\/$/, ""); + const mapped = environmentMap()[product]?.[environment]; + if (mapped) return String(mapped).replace(/\/$/, ""); + // An environment was named and nothing defines its host. Falling back to the harness + // default here would send a preprod deployment at production, silently. + if (harnessBaseUrl || product === "tm") { + throw new InvocationError( + `environment '${environment}' has no host for product '${product}'. Set ` + + `CAPABILITY_REGISTRY_BASE_URL_${key}_${environment.toUpperCase()} or add it to ` + + `CAPABILITY_REGISTRY_BASE_URLS.`, + ); + } + } - // 2. Then whatever the HARNESS declared, carried through in the artifact. - // NOTE the sharp edge: a harness-declared host is one fixed origin, so declaring one - // for a region-sharded product would send EU and IN accounts to the wrong region. - // tm therefore declares none on purpose and falls through to discovery below. + // NOTE the sharp edge: a harness-declared host is one fixed origin, so declaring one for a + // region-sharded product would send EU and IN accounts to the wrong region. tm declares + // none for exactly that reason and falls through to discovery. if (harnessBaseUrl) return harnessBaseUrl.replace(/\/$/, ""); - // 3. Product-specific discovery. tm is region-sharded and the package already probes - // test-management{,-eu,-in} with the caller's credentials to find their account's. if (product === "tm") return (await getTMBaseURL(config)).replace(/\/$/, ""); - // 4. Refuse rather than guess. A guessed host fails as a DNS error or a 404 that reads - // like the caller's problem, when it is our missing configuration. throw new InvocationError( `no host is configured for product '${product}': the harness declares none and there ` + - `is no override. Set CAPABILITY_REGISTRY_BASE_URL_${product.toUpperCase()}.`, + `is no override. Set CAPABILITY_REGISTRY_BASE_URL_${key}.`, ); } diff --git a/tests/tools/capabilityRegistryRegion.test.ts b/tests/tools/capabilityRegistryRegion.test.ts index 6093654..5087e8f 100644 --- a/tests/tools/capabilityRegistryRegion.test.ts +++ b/tests/tools/capabilityRegistryRegion.test.ts @@ -99,3 +99,63 @@ describe("harness declares, config overrides — the same precedence Atlas uses" .toBe("https://test-management-eu.browserstack.com"); }); }); + +describe("multiple environments, the way Atlas defines them", () => { + const HARNESS_HOST = "https://tm.harness-declared.example"; + + async function resolver() { + return (await import("../../src/tools/capability-registry/config.js")).resolveBaseUrl; + } + + afterEach(() => { + delete process.env.CAPABILITY_REGISTRY_ENV; + delete process.env.CAPABILITY_REGISTRY_BASE_URLS; + delete process.env.CAPABILITY_REGISTRY_BASE_URL_TM_PREPROD; + }); + + it("picks this environment's host from a per-env variable", async () => { + process.env.CAPABILITY_REGISTRY_ENV = "preprod"; + process.env.CAPABILITY_REGISTRY_BASE_URL_TM_PREPROD = "https://tm-preprod.example/"; + expect(await (await resolver())("tm", CONFIG, HARNESS_HOST)) + .toBe("https://tm-preprod.example"); + }); + + it("picks it from one map, the analogue of harness.extra_environments", async () => { + process.env.CAPABILITY_REGISTRY_ENV = "preprod"; + process.env.CAPABILITY_REGISTRY_BASE_URLS = JSON.stringify({ + tm: { preprod: "https://tm-preprod.example", prod: "https://tm.example" }, + }); + expect(await (await resolver())("tm", CONFIG, HARNESS_HOST)) + .toBe("https://tm-preprod.example"); + }); + + it("lets the environment-agnostic override win, as Atlas's session seam does", async () => { + process.env.CAPABILITY_REGISTRY_ENV = "preprod"; + process.env.CAPABILITY_REGISTRY_BASE_URL_TM = "https://seam.example"; + process.env.CAPABILITY_REGISTRY_BASE_URL_TM_PREPROD = "https://tm-preprod.example"; + expect(await (await resolver())("tm", CONFIG, HARNESS_HOST)).toBe("https://seam.example"); + delete process.env.CAPABILITY_REGISTRY_BASE_URL_TM; + }); + + it("refuses when an environment is named but has no host, rather than falling back", async () => { + // Falling through to the harness default here would point a preprod deployment at + // production, silently — the worst available outcome. + process.env.CAPABILITY_REGISTRY_ENV = "preprod"; + await expect((await resolver())("tm", CONFIG, HARNESS_HOST)) + .rejects.toThrow(/environment 'preprod' has no host/); + }); + + it("refuses a malformed map instead of reading it as 'no override'", async () => { + process.env.CAPABILITY_REGISTRY_ENV = "preprod"; + process.env.CAPABILITY_REGISTRY_BASE_URLS = "{not json"; + await expect((await resolver())("tm", CONFIG, HARNESS_HOST)) + .rejects.toThrow(/not valid JSON/); + }); + + it("ignores the environment entirely when none is selected", async () => { + process.env.CAPABILITY_REGISTRY_BASE_URLS = JSON.stringify({ + tm: { preprod: "https://tm-preprod.example" }, + }); + expect(await (await resolver())("tm", CONFIG, HARNESS_HOST)).toBe(HARNESS_HOST); + }); +}); From 43eba700f59e02716449714a9f020c79cf31a99b Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Wed, 19 Aug 2026 23:12:14 +0530 Subject: [PATCH 05/31] Return the product's response instead of interpreting it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `invokeEndpoint` now answers {ok, completed, http_response:{status, body}} and nothing else. Removed: row extraction by shape, the `returns` allowlist, the scalars-only filter for undeclared schemas, item counting, ordering, trimming, internal paging, and the guards that reported an empty projection as a registration defect. envelope.ts is deleted outright. WHY, from a live failure. Every one of those was a place we could be wrong ABOUT a correct answer, and when we were, the caller saw a confident empty result rather than an error. `GET .../test-case/priority` returned `capability_shape_empty: the product answered with rows, but every field on them was an object, an array, or a name withheld as sensitive` — while in truth the extractor had looked one level too shallow (the response is keyed by the field asked for, `{priority: {values: […]}}`) and the scalars-only rule then dropped the only field left. A complete answer, reported as "this endpoint can report nothing". The same call now returns 11 option ids, which are what every priority filter and write needs. `ok` mirrors the HTTP status; nothing else decides it. A non-2xx returns the product's OWN body, which usually says more than we could ("Drill-down is only available for User Workload Reports") — inventing a message discarded it. `error` is set only when status is 0, i.e. there was no response to speak for itself. `completed` reads one declared field (`info.next`) so a caller knows another page exists; it is a peek, not a reshaping. ONE REQUEST PER CALL, so paging is the caller's. That required publishing `p`, the page-size parameter and `max_page_size` with each paginated endpoint — they were hidden only while this side walked the pages itself, and withholding them would leave a 913-row read stuck on page one at the product's default of 30. Response headers are NOT returned. Measured on preprod: 26 of them, ~20 varnish and timing noise, and `set-cookie` present — which matters because tm's own auth scheme IS a cookie, so that header is credential-shaped rather than metadata. 323 tests, lint and tsc clean, live-verified against preprod. --- capability-index.json | 2 +- src/tools/capability-registry/envelope.ts | 147 ----------------- src/tools/capability-registry/index-loader.ts | 13 +- src/tools/capability-registry/register.ts | 5 +- src/tools/capability-registry/resolve.ts | 151 ++++++------------ src/tools/capability-registry/types.ts | 9 +- tests/fixtures/registry-index.json | 2 +- tests/tools/capabilityRegistry.test.ts | 142 +++++----------- tests/tools/capabilityRegistryE2E.test.ts | 12 +- 9 files changed, 110 insertions(+), 373 deletions(-) delete mode 100644 src/tools/capability-registry/envelope.ts diff --git a/capability-index.json b/capability-index.json index 6a8157d..1c30ff6 100644 --- a/capability-index.json +++ b/capability-index.json @@ -1 +1 @@ -{"schema_version":1,"build_id":"3c872d0b7b-173caps-8bf0e50","harness_commit":"8bf0e508","products":{"tm":{"summary":"BrowserStack Test Management API (v1 \u2014 the SSO/OAuth app API, cookie-authenticated).","capabilities":[{"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"}],"body":[{"name":"owner","type":"integer","description":"The ID of the user to assign as the owner of the test run."}],"intent":"Assign a owner to the test run","guidance":["Assign a user as the owner of a specific test run within a project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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, verified live: 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":"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":["Archive multiple test cases within a project","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"paginated":true},{"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,"description":"Plan INTEGER ids."}],"intent":"Archive test plans in bulk (reversible \u2014 prefer this over delete)","guidance":["REVERSIBLY remove plans from view","Async above the bulk threshold","Soft-archive one or more plans","reach for it whenever the user wants plans \"removed\" or \"cleaned up\" without saying they must be destroyed"],"returns":["async","unique_id"]},{"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"}],"query":[{"name":"include_subfolders_tc","type":"boolean"}],"body":[{"name":"assignee_id","type":"integer","example":2,"description":"ID of the assignee to whom the test cases will be assigned"},{"name":"mapping_ids","type":"array","example":[999,991,1024,1019],"description":"List of test case IDs to be linked"},{"name":"select_all","type":"boolean","example":false,"description":"Flag to select all test cases"},{"name":"de_selected_ids","type":"array","description":"List of test case IDs to be de-selected"},{"name":"folder_id","type":"integer","description":"ID of the folder to which the test cases belong"}],"intent":"Bulk assign test cases to a test run","guidance":["assign specific cases within a run to a user (async above 300)","Assign multiple test cases to a specific test run within a project, allowing for bulk operations on test case assignments"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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` \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, verified live: an unrecognised key here is SILENTLY DROPPED, so a typo widens the ta"},{"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,"json_path":"/test_case/ids"},{"name":"de_selected_ids","type":"array","required":true,"json_path":"/test_case/de_selected_ids"},{"name":"select_all","type":"boolean","required":true,"json_path":"/test_case/select_all"},{"name":"folder_id","type":"string","description":"SOURCE folder that scopes the selection, at top level. OPTIONAL when you pass explicit `ids` (verified live: the copy succeeds without it), but send it whenever `select_all` is true \u2014 it is what bounds the set, and `select_all` with `folder_id` and no `q` copies exactly that folder's cases. Integer and string are both accepted; the declared string matches what the UI sends here, which unlike bulk-"},{"name":"destination_folder_id","type":"integer","required":true,"description":"Target folder for the copies, and the one genuinely required destination field \u2014 omitting it is a bare `400`, verified live. NOTE it is TOP-LEVEL for copy, unlike bulk-move where the destination sits inside `test_case`."},{"name":"destination_project_id","type":"string","description":"Target project id. Needed only for a copy to ANOTHER project. For a copy WITHIN one project it is OPTIONAL \u2014 verified live, omitting it copies correctly into `destination_folder_id`, and integer and string are both accepted. The product does both: its Copy/Move modal sends the source project id, its clone / drag-and-drop path omits it. Send the source id to be explicit or omit it; never invent a v"}],"intent":"Bulk copy test cases","guidance":["copy a selection to a folder (async above 30)","Bulk copy test cases from one folder to another within a project","This operation allows you to copy multiple test cases at once, which can be useful for organizing or duplicating test cases across different folders","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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` \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, verified live: 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":"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 delete.","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":"Delete every case in scope, minus `de_selected_ids`.","json_path":"/test_case/select_all"}],"intent":"Bulk Delete Test Cases from Search","guidance":["All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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."}],"intent":"Delete test results in bulk \u2014 PARTIAL SUCCESS is normal, always read `skipped`","guidance":["PARTIAL SUCCESS is normal, always read the skipped array back","Authorisation is enforced per id (a caller must be the result's author or a project admin)","treating a 200 as \"all deleted\" will silently misreport","result_ids must be a non-empty array (400 otherwise) of at most 300 ids (422 beyond that)","batch larger sets yourself"],"returns":["id","reason"]},{"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` \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, verified live: 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":"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 edit.","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":"automation_status","type":"string","json_path":"/test_case/automation_status"},{"name":"case_type","type":"integer","json_path":"/test_case/case_type"},{"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"},{"name":"owner","type":"integer","description":"TM user id.","json_path":"/test_case/owner"},{"name":"preconditions","type":"string","json_path":"/test_case/preconditions"},{"name":"priority","type":"integer","json_path":"/test_case/priority"},{"name":"status","type":"integer","json_path":"/test_case/status"},{"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"}],"intent":"Bulk Update Test Cases","guidance":["bare values, REPLACE-only (async above 100)","Update multiple test cases within a project","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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"}],"query":[{"name":"include_subfolders_tc","type":"boolean"},{"name":"status_id","type":"integer"}],"body":[{"name":"attachments","type":"array","example":[]},{"name":"de_selected_ids","type":"array","example":[]},{"name":"description","type":"string","example":"

something

","description":"HTML formatted comment or note"},{"name":"folder_id","type":"integer","example":21},{"name":"issues","type":"array","example":[]},{"name":"mapping_ids","type":"array","example":[999,991,1024,1019]},{"name":"select_all","type":"boolean","example":false},{"name":"status_id","type":"integer","example":21},{"name":"test_run_ids","type":"array","example":[1001,1002,1003],"description":"Array of BTCER IDs to apply the bulk edit to (passed when automation = true)"},{"name":"test_case_counts","type":"array","example":[1,3,2],"description":"Array of test case counts corresponding to each BTCER ID in test_run_ids (passed when automation = true)"}],"intent":"Bulk edit test cases result in test run","guidance":["Update multiple test cases in a specific test run within a project, allowing for bulk operations such as changing status, adding attachments, and more"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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, verified live: an unrecognised key here is SILENTLY DROPPED, so a typo widens the ta"},{"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},{"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},{"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`.\n\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},{"name":"value","type":"string"}],"json_path":"/test_case/priority"},{"name":"status","type":"string","fields":[{"name":"operation","type":"string","required":true},{"name":"value","type":"string"}],"json_path":"/test_case/status"},{"name":"case_type","type":"string","fields":[{"name":"operation","type":"string","required":true},{"name":"value","type":"string"}],"json_path":"/test_case/case_type"},{"name":"automation_state","type":"string","fields":[{"name":"operation","type":"string","required":true},{"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},{"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},{"name":"value","type":"string"}],"json_path":"/test_case/owner"},{"name":"preconditions","type":"string","description":"Text value.","fields":[{"name":"operation","type":"string","required":true},{"name":"value","type":"string"}],"json_path":"/test_case/preconditions"},{"name":"review_status","type":"string","fields":[{"name":"operation","type":"string","required":true},{"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},{"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":["PREFERRED bulk write","per-field { value, operation }","Correct for one case too (pass a single id)","Update many test cases in ONE call","Every field is an object of the form {\"value\": \u2026, \"operation\": \u2026}","so this is the only write path that can ADD or REMOVE from a collection without replacing it"],"returns":["async","unique_id"],"paginated":true},{"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` \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, verified live: 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":"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 move.","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":"Move every case in scope, minus `de_selected_ids`.","json_path":"/test_case/select_all"},{"name":"destination_folder_id","type":"integer","description":"Target folder id. Falls back to `folder_id` when omitted.","json_path":"/test_case/destination_folder_id"},{"name":"folder_id","type":"integer","description":"Legacy alias for `destination_folder_id`, used only when that is absent.","json_path":"/test_case/folder_id"},{"name":"destination_project_id","type":"integer","description":"Set only for a cross-project move. Shared cases cannot be moved across projects (422).","json_path":"/test_case/destination_project_id"}],"intent":"Bulk Move Test Cases","guidance":["Move multiple test cases from one folder to another within a project","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"paginated":true},{"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":"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, verified live: 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":"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 Retrieve Test Cases","guidance":["Retrieve multiple test cases within a project based on specified criteria","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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,"description":"Plan INTEGER ids."}],"intent":"Un-archive test plans in bulk (undo bulk-archive)","guidance":["Restore previously archived plans"],"returns":["async","unique_id"]},{"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":"Clone an exploratory session (async when it has many logs).","guidance":["clone a session (async when it has many logs)"]},{"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","description":"Name for the clone. Defaults to a server-generated copy name.","json_path":"/test_plan/name"},{"name":"copy_all_sub_plans","type":"boolean","json_path":"/test_plan/copy_all_sub_plans"},{"name":"sub_plan_ids","type":"array","description":"Clone only these sub-plans. Ignored when copy_all_sub_plans is true.","json_path":"/test_plan/sub_plan_ids"}],"intent":"Clone a test plan, optionally with its sub-plans","guidance":["copy_all_sub_plans or sub_plan_ids to bring its sub-plans along","Copy an existing plan","copy_all_sub_plans: true clones every sub-plan","alternatively pass sub_plan_ids to clone a specific subset","Omit both to clone just the plan itself"]},{"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"}],"body":[{"name":"copy_tc_assignee","type":"boolean","description":"Copy test case assignees from the original run"},{"name":"copy_issues","type":"boolean","description":"Copy issues linked to the original run"},{"name":"copy_tags","type":"boolean","description":"Copy tags from the original run"},{"name":"copy_all_cases","type":"boolean","description":"Include all test cases in the cloned run"},{"name":"status","type":"array","example":["passed","failed"],"description":"Status strings to filter test cases to copy"},{"name":"status_id","type":"array","example":[1,2],"description":"Status IDs to filter test cases to copy"},{"name":"name","type":"string","example":"Cloned Test Run - 2025","description":"Name for the new cloned test run"}],"intent":"Clone a test run","guidance":["Create a new test run by cloning an existing one, with options to copy test case assignees, issues, tags, and filter cases by status"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"Close an exploratory session","guidance":["Transitions an active exploratory session to the closed state"]},{"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"}],"intent":"Close a test run","guidance":["Close a specific test run within a project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"ID of folder to copy"},{"name":"destination_folder_id","type":"integer","required":true,"example":3,"description":"ID of destination parent folder"},{"name":"destination_project_id","type":"integer","required":true,"example":10000,"description":"Destination project ID (can be same or different)"},{"name":"async","type":"boolean","example":true,"description":"Whether to process asynchronously via background job"},{"name":"copy_test_cases","type":"boolean","example":true,"description":"Whether to copy test cases within folder"}],"intent":"Copy a folder to another location","guidance":["Copies a folder (and optionally its contents) to a destination folder within the same or different project","Supports both synchronous and asynchronous operations via the async flag","Async Processing: When async: true, the operation is queued as a Sidekiq background job and returns immediately with a unique tracking ID"],"returns":["async","folder_id","unique_id"]},{"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","example":false,"description":"Select every case in the project.","json_path":"/selection/select_all"},{"name":"unique_test_case_count","type":"integer","example":2,"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.","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`."},{"name":"configurations","type":"array","required":true,"example":[],"description":"Configuration ids. Required \u2014 pass an empty array if there are none."},{"name":"trtc_configuration_mappings","type":"array","description":"Per-configuration case mappings. An ARRAY of objects \u2014 the server rejects an object here.","fields":[{"name":"configuration_id","type":"integer"},{"name":"select_all","type":"boolean"},{"name":"linked_test_cases","type":"array"},{"name":"unlinked_test_cases","type":"array"}]}],"intent":"Count the test cases a selection resolves to (incl. configurations/datasets).","guidance":["count the cases a selection resolves to (incl"],"shape":"discovered"},{"method":"GET","path":"/api/v1/projects/{project_id}/test-plans/{test_plan_id}/test-runs/count","mode":"read","entity":"test_plan","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"test_plan_id","type":"integer","required":true}],"intent":"Count the test runs linked to a test plan","guidance":["how many runs a plan groups","cheaper and safer than paging the run list","Returns just the count of runs linked to the plan","Prefer this over paging the run list when the user only asked \"how many\"","list reads truncate around 30 KB and will make you under-count"],"shape":"discovered"},{"method":"POST","path":"/api/v1/projects/{project_id}/folder/{folder_id}/bulk-test-cases","mode":"write","entity":"folder","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"folder_id","type":"integer","required":true}],"body":[{"name":"test_cases","type":"array","required":true,"fields":[{"name":"name","type":"string","required":true},{"name":"test_case_folder_id","type":"string","required":true},{"name":"attachments","type":"array"},{"name":"automation_state","type":"integer"},{"name":"automation_status","type":"string"},{"name":"case_type","type":"integer"},{"name":"custom_fields","type":"object"},{"name":"description","type":"string"},{"name":"estimate","type":"string"},{"name":"estimated_duration_seconds","type":"integer"},{"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"},{"name":"reviewers","type":"array"},{"name":"send_for_review","type":"boolean"},{"name":"shared_precondition_id","type":"integer"},{"name":"status","type":"integer"},{"name":"tags","type":"array"},{"name":"template","type":"string"},{"name":"template_id","type":"integer"},{"name":"template_step_type","type":"string"},{"name":"test_case_dataset","type":"string"},{"name":"test_case_steps","type":"array"}]},{"name":"unique_id","type":"string","description":"Client-supplied idempotency key. When present and the group has the async bulk-create feature flag enabled, the request is processed asynchronously and acknowledged with 202; completion is delivered over the common WebSocket channel keyed by this id."}],"intent":"Create test cases in bulk for a folder (\u226410) \u2014 UNRELIABLE STATUS, see description.","guidance":["Creates several test cases in one request","more returns 400 \"More than permitted test cases sent\"","A 500 here does NOT mean nothing happened","The action is wrapped in a catch-all rescue that renders 500 {\"success\": false, \"message\": \"Failed to create test cases.\"} for any exception, including ones raised AFTER the cases were already inserted","Same status, same message, opposite outcomes","So on a 500: never retry blind"],"returns":["request_trace_id"]},{"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 new configuration for a project","guidance":["create a configuration ({ name })","Create a new configuration in a specific project"],"returns":["id","name","created_at","group_id","is_suggested","record_status"]},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"body":[{"name":"option_value","type":"string","required":true,"description":"The option label."},{"name":"is_default","type":"boolean","required":true,"description":"REQUIRED \u2014 a missing value is a 400. Must be 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 parent option this hangs under."}],"intent":"Add one option to a dataset \u2014 how you add a dropdown value.","guidance":["ADD one dropdown option","option_value + is_default both required","Adds a single option","Both option_value and is_default are required","a missing is_default is 400 \"Invalid Params\"","For dropdown, at most one option may be the default"]},{"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}],"body":[{"name":"linked_projects","type":"array","description":"Project INTEGER ids this dataset applies to. This is what makes the field visible in a project \u2014 a dataset with no linked projects leaves the field invisible everywhere. NOTE the spelling differs from the project-mappings endpoint, which uses `link_projects` / `unlink_projects`."},{"name":"link_to_future_projects","type":"boolean","description":"Auto-link projects created later."},{"name":"linked_to_all_projects","type":"boolean","description":"Apply to every project in the workspace."},{"name":"options","type":"array","description":"The allowed values, for dropdown-style fields.","fields":[{"name":"option_value","type":"string","required":true},{"name":"is_default","type":"boolean","required":true},{"name":"parent_option_id","type":"integer"}]}],"intent":"Add a dataset (an option set + its project links) to an existing field.","guidance":["add another option set + project scope to an existing field","A dataset is how a field carries options and project scope","A field can hold more than one, letting different projects see different option sets for the same field","the key is linked_projects, and options is accepted at this level (it is a dataset body, not a field body)","for other types a dataset with linked_projects and no options is what scopes the field to projects"]},{"method":"POST","path":"/api/v1/admin-v2/settings/custom-fields","mode":"write","entity":"custom_field","body":[{"name":"field_name","type":"string","required":true},{"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 (e.g. `field_dropdown`) are a different API's and are rejected. Silently immutable after creation: an update that changes it returns 200 and changes nothing."},{"name":"entity_type","type":"string","values":["TestCase","TestResult","TestPlan","ExploratorySession"],"description":"Which entity the field hangs off. One field belongs to exactly one."},{"name":"is_required","type":"boolean","required":true,"description":"REQUIRED, and must be a literal boolean \u2014 omitting it is a 400."},{"name":"datasets","type":"array","required":true,"description":"REQUIRED for every field type, dropdown or not \u2014 omitting it is a 400. Carries the options and the project scope. Send one entry with `linked_projects` set.","fields":[{"name":"linked_projects","type":"array"},{"name":"link_to_future_projects","type":"boolean"},{"name":"linked_to_all_projects","type":"boolean"},{"name":"options","type":"array"}]},{"name":"place_holder_text","type":"string"},{"name":"default_value","type":"string","description":"Only honoured when `field_type` is `boolean`."},{"name":"levels","type":"array","description":"REQUIRED when field_type is `nested_dropdown`, minimum 2 entries, each {name, level_type}."}],"intent":"Create a custom field DEFINITION (workspace-admin action).","guidance":["options AND project scope both go in datasets[]","Creates the field definition","a new attribute available on the chosen entity type","configurable per workspace","so don't predict access","A caller without it gets 403"]},{"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,"description":"Name of the dataset.","json_path":"/dataset/name"},{"name":"variables","type":"array","required":true,"description":"List of dataset variables/columns (max 40).","fields":[{"name":"name","type":"string","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/variables"},{"name":"rows","type":"array","required":true,"description":"List of dataset rows (max 100).","fields":[{"name":"row_number","type":"integer","required":true},{"name":"row_data","type":"array","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/rows"}],"intent":"Create a new dataset.","guidance":["create a dataset with variables + rows","Create a new dataset with variables and rows for a project"],"returns":["name","created_at","rows_count","uuid"]},{"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.","json_path":"/exploratory_session/title"},{"name":"description","type":"string","example":"Explore edge cases in the checkout flow.","description":"Optional description.","json_path":"/exploratory_session/description"},{"name":"charter","type":"string","example":"Focus on payment error handling","description":"Testing charter describing the scope and goals.","json_path":"/exploratory_session/charter"},{"name":"timebox_duration","type":"integer","example":60,"description":"Planned duration in minutes.","json_path":"/exploratory_session/timebox_duration"},{"name":"owner","type":"integer","example":42,"description":"TCM user ID of the session owner / assignee.","json_path":"/exploratory_session/owner"},{"name":"assignee","type":"integer","example":42,"description":"Alias for `owner`. TCM user ID of the assignee.","json_path":"/exploratory_session/assignee"},{"name":"folder_id","type":"integer","example":456,"description":"ID of the folder to place this session in.","json_path":"/exploratory_session/folder_id"},{"name":"tags","type":"array","example":["regression","mobile"],"description":"Tags for the session.","json_path":"/exploratory_session/tags"},{"name":"configurations","type":"array","example":[1,3],"description":"Configuration IDs to associate with this session.","json_path":"/exploratory_session/configurations"},{"name":"attachments","type":"array","example":[101,102],"description":"Blob / media attachment IDs.","json_path":"/exploratory_session/attachments"},{"name":"issues","type":"array","description":"Issues to link to this session.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/exploratory_session/issues"}],"intent":"Create an exploratory session","guidance":["body wrapped in exploratory_session (title required)","Creates a new exploratory session within the specified project"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"]},{"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 button \u2014 spinner appeared but order was not created.

","description":"Rich-text HTML content of the log entry.","json_path":"/session_log/content"},{"name":"status","type":"string","values":["pass","fail","bug","retest","block","note"],"example":"fail","description":"Test result status for this log step.","json_path":"/session_log/status"},{"name":"elapsed_time","type":"integer","example":120,"description":"Time elapsed for this step in seconds.","json_path":"/session_log/elapsed_time"},{"name":"defects","type":"array","description":"Defects to link to this log entry.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/session_log/defects"}],"intent":"Create a session log entry","guidance":["add a log entry (rich-text HTML, sanitised on write)","Adds a new log entry to the specified exploratory session","Rich-text HTML content is sanitised before storage"],"returns":["id","status","content","created_at","elapsed_time","session_id","updated_at"]},{"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,"description":"Name of the filter"},{"name":"entity","type":"string","required":true,"values":["testcase","testplan","testrun","exploratory_session","test_plan_test_case"],"description":"Entity type the filter applies to. The server's validator accepts five values (the\nlast two were missing here); an unlisted value returns 400 \"Invalid JSON data\"."},{"name":"is_project_level","type":"boolean","description":"Whether this filter is available at project level"},{"name":"filters","type":"object","required":true,"description":"Filter criteria object containing the actual filter conditions"}],"intent":"Create a new filter","guidance":["save a filter view ({ name, entity, filters, is_project_level })","Create a new saved filter for test cases, test plans, or test runs in a project"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"]},{"method":"POST","path":"/api/v1/projects","mode":"write","entity":"project","body":[{"name":"name","type":"string","required":true,"json_path":"/project/name"},{"name":"description","type":"string","json_path":"/project/description"}],"intent":"Create a new project.","guidance":["Pinned to human approval","Create a new project with name and description"],"returns":["name","description"]},{"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},{"name":"report_type","type":"string","required":true,"values":["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","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_statu"},{"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 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`"},{"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;\n`custom_report` is a user-configured one. For a Requirement Traceability\nreport, `system_generated` makes the server auto-populate\n`report_filters.requirements` with every requirement in the project."},{"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"},{"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":"Integer run ids \u2014 pairs with report_timeframe=specific_test_runs.","json_path":"/report_filters/test_runs"},{"name":"test_plans","type":"array","description":"Integer plan ids \u2014 pairs with report_timeframe=specific_test_plans.","json_path":"/report_filters/test_plans"},{"name":"test_cases","type":"string","description":"Case selection \u2014 pairs with report_timeframe=specific_test_cases. FOLDER-KEYED\n(see SelectionData), never a flat id list: the server resolves the selection\nthrough its folder map, so any other shape yields zero cases and saves an\nUNSCOPED, project-wide report at 200.\n\nSend all three keys. Folder ids are STRING keys; test-case ids are INTEGERS\n(the numeric `id`, not the TC-NNN identifier) \u2014 take bo","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":"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.","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 } }.","json_path":"/report_filters/test_plan_selection"},{"name":"builds","type":"array","json_path":"/report_filters/builds"},{"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\n`Test Run Summary`, `Test Run Detailed Report` and `Test Case Activity` \u2014 on\nthe other six report types the server never reads it and drops it silently.\nPersisted (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`."},{"name":"users","type":"array","json_path":"/mail_to/users"},{"name":"external_mails","type":"array","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\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"},{"name":"time","type":"string","example":"08:00","description":"24-hour HH:MM, UTC.","json_path":"/frequency_details/time"},{"name":"day","type":"string","example":"monday","description":"Required for weekly (lowercase day name) and monthly (integer 1-28).\nOmit for daily.","json_path":"/frequency_details/day"}],"intent":"Create a new scheduled report for a project","guidance":["Create a report configuration under the given project"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"]},{"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","description":"Name of the root folder"},{"name":"notes","type":"string","example":"This folder contains all regression test cases","description":"Optional notes or description for the folder"}],"intent":"Create a root-level folder for test cases in a project","guidance":["Create a new root-level folder for organizing test cases within a project"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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},{"name":"folder_id","type":"integer","description":"ID of the shared-field folder to place the shared step in. Null or omitted means the root (Unassigned)."},{"name":"shared_step_details","type":"array","required":true,"fields":[{"name":"order","type":"integer","required":true},{"name":"step","type":"string"},{"name":"result","type":"string"},{"name":"test_data","type":"string"}]}],"intent":"Create a new shared step in a project","guidance":["Create a new shared step within a project"],"returns":["id","title","step_count","test_case_count"]},{"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},{"name":"test_run_id","type":"string","required":true},{"name":"test_case_id","type":"integer","required":true},{"name":"step_id","type":"integer","required":true}],"body":[{"name":"status_id","type":"integer","json_path":"/test_run_step_result/status_id"},{"name":"description","type":"string","json_path":"/test_run_step_result/description"}],"intent":"Record a per-step result for a case in a run (v1).","guidance":["record a per-step outcome for a step-templated case ({ status_id, description })"]},{"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,"description":"Name of the new folder.","json_path":"/folder/name"},{"name":"notes","type":"string","description":"Description of the new folder.","json_path":"/folder/notes"}],"intent":"create subfolder inside a specific folder","guidance":["Create a new subfolder within a specific folder in a project"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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 the FIRST test case in a project that has no folders (needs create_at_root).","guidance":["Narrow-purpose route: the first case in a project that has zero folders","Verified live on a folderless project: 200, and the folder appears","The route and the flag always travel together","The product's UI derives both from one boolean","no folders exist AND the user picked no folder","A correct refusal, not something to retry: list the folders and pick one"],"returns":["result","step"]},{"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,"fields":[{"name":"name","type":"string","required":true},{"name":"test_case_folder_id","type":"string","required":true},{"name":"attachments","type":"array"},{"name":"automation_state","type":"integer"},{"name":"automation_status","type":"string"},{"name":"case_type","type":"integer"},{"name":"custom_fields","type":"object"},{"name":"description","type":"string"},{"name":"estimate","type":"string"},{"name":"estimated_duration_seconds","type":"integer"},{"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"},{"name":"reviewers","type":"array"},{"name":"send_for_review","type":"boolean"},{"name":"shared_precondition_id","type":"integer"},{"name":"status","type":"integer"},{"name":"tags","type":"array"},{"name":"template","type":"string"},{"name":"template_id","type":"integer"},{"name":"template_step_type","type":"string"},{"name":"test_case_dataset","type":"string"},{"name":"test_case_steps","type":"array"}]},{"name":"create_at_root","type":"boolean"}],"intent":"Create test cases for a folder in a project.","guidance":["create a case in a folder","body wrapped in test_case, name required","Create one or more test cases within a specific folder in a project","If create_at_root is true, the test case will be created at the root of the project"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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","json_path":"/test_plan/name"},{"name":"description","type":"string","json_path":"/test_plan/description"},{"name":"start_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/start_date"},{"name":"end_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/end_date"},{"name":"plan_status","type":"string","description":"Plan status. The list filter accepts new / started / completed.","json_path":"/test_plan/plan_status"},{"name":"owner","type":"integer","description":"Owner user id \u2014 resolve via the project users read first.","json_path":"/test_plan/owner"},{"name":"parent_plan_id","type":"integer","description":"Parent plan's INTEGER id. Set it to create (or re-parent into) a SUB-plan \u2014 the\nresult carries an STP-NNN identifier. Null/omitted means a top-level plan.","json_path":"/test_plan/parent_plan_id"},{"name":"tags","type":"array","json_path":"/test_plan/tags"},{"name":"reviewers","type":"array","description":"Reviewer user ids.","json_path":"/test_plan/reviewers"},{"name":"attachments","type":"array","description":"Attachment ids already uploaded via the attachments surface.","json_path":"/test_plan/attachments"},{"name":"custom_fields","type":"object","json_path":"/test_plan/custom_fields"},{"name":"issues","type":"array","description":"Linked tracker issues. Structured \u2014 NOT a flat array of keys.","fields":[{"name":"issue_id","type":"string"},{"name":"issue_type","type":"string"},{"name":"metadata","type":"object"}],"json_path":"/test_plan/issues"},{"name":"test_runs","type":"string","description":"The plan's linked test runs. Accepts EITHER an array of test-run integer ids, OR a\nbulk-selection object for \"every run matching this filter\". On update this REPLACES\nthe existing link set rather than appending.","json_path":"/test_plan/test_runs"}],"intent":"Create a test plan \u2014 or a SUB-plan, by setting parent_plan_id","guidance":["body wrapped in test_plan","Create a test plan in the project","Everything goes under the test_plan key","test_runs accepts two shapes","Both are honoured server-side","an unknown option is rejected"]},{"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"},{"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":["log a NEW result for a case in a run","Create a new test result for a specific test case within a test run in a project"],"returns":["id","status","backtrace","configuration_id","created_at","created_by_imported","custom_fields","description","error_description","expanded","failure_type","finished_at"]},{"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],"description":"Explicit case ids to pin into the run. Top level, NOT inside `test_run`. Use this or `selection`."},{"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":["Create a new test run for a specific project, which can be used to execute test cases"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"dataset_id","type":"integer","required":true},{"name":"option_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"Delete one option (POST to /delete) \u2014 DESTRUCTIVE, drops values using it.","guidance":["destroys the values that used it","Removing an option deletes the stored values that used it, across every project linked to the dataset","that preserves the values"]},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"Delete a dataset (POST to /delete) \u2014 DESTRUCTIVE, drops its options and values.","guidance":["drops its options and the values the linked projects stored","Removes the option set and unlinks its projects, destroying the values those projects had stored for this field","Name the affected projects before calling"]},{"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}],"intent":"Delete a field definition (POST to /delete) \u2014 DESTRUCTIVE, cascades to stored values.","guidance":["destroys every stored value in every linked project","Name the projects first","Removes the definition and every value stored for it, in every project it is linked to","There is no bin and no undo","Before calling: read the field, say how many projects it is linked to, and name them","If the user only wants it hidden rather than destroyed, that is a different ask"]},{"method":"DELETE","path":"/api/v1/projects/{project_id}/datasets/{uuid}","mode":"destructive","entity":"dataset","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"uuid","type":"string","required":true}],"intent":"Delete a dataset.","guidance":["say it isn't available rather than calling it, and don't substitute by parsing the file yourself","A 422 here means NOT-ENTITLED, not a bad payload","say so and stop, don't retry with a different body","BINDING shape is DIFFERENT from the dataset shape, and this is the usual failure","the dataset's uuid repeated on every column it owns","The binding object has NO top-level uuid or name (both stripped by strong params)"]},{"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":"Delete an exploratory session","guidance":["Deletes an exploratory session"]},{"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":"Delete a session log entry","guidance":["Permanently deletes a log entry from the specified exploratory session"]},{"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 filter","guidance":["entity = testcase | testplan | testrun | exploratory_session","filter_id is an integer","reuse a saved view's filters as the q for a later search or bulk action"]},{"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":"Delete a folder and its contents","guidance":["Deletes a folder by its ID and optionally returns the parent folder's path hierarchy"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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"}],"intent":"Delete a report (this removes the report itself, not just its schedule)","guidance":["returns the remaining reports","merely stopping a report from recurring","The response is the list of remaining reports","There is no bin and no undo","so confirm the report's name with the user first"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"]},{"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","guidance":["affects every test case embedding it","Deletes a shared step from a project"]},{"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":"Delete an attachment from a test case in a folder in a project","guidance":["read the file from that url"],"returns":["id","byte_size","content_type","filename"]},{"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":"Delete a test plan (or sub-plan) \u2014 no bin, no undo","guidance":["The server performs no plan-type check","so this deletes a sub-plan by its own integer id exactly the same way","Deleting a parent plan is destructive to its children"]},{"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 test result","guidance":["Prefer PATCHing the latest result to correct a mistake","Deleting a result removes an execution record","the case's latest status is recomputed from what remains"]},{"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"}],"intent":"delete test run","guidance":["The discovered ids must be CARRIED INTO the create body","discovery alone puts nothing in the run","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","It is validated before the selection","so a bare 400 'invalid data' means a bad test_run field"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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},{"name":"duplicate_id","type":"integer","required":true}],"body":[{"name":"discard_reason","type":"string","description":"Short reason code / label for why this isn't a duplicate."},{"name":"user_feedback","type":"string","description":"Free-text feedback from the user."}],"intent":"Discard a duplicate suggestion \u2014 dismisses it WITHOUT touching test cases","guidance":["DISCARD a suggestion","dismisses it WITHOUT touching either test case","The safe default when the user says it's not a duplicate or is unsure","Mark a recommendation as discarded","This is the safe way to dismiss a suggestion: it changes only the recommendation's status to discarded and leaves both test cases exactly as they are","discard_reason and user_feedback are optional but feed the dedupe model"]},{"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":"Desired export format. Note this is a STRING here, while the same-named field\non `send_email_now` is an ARRAY."},{"name":"required","type":"object","description":"Optional column selection, honoured only for `Test Run Detailed Report`\n(ignored for every other type). Shape:\n`{ \"\": { \"fields\": [\"id\",\"title\",...] } }`. Omit to get the report's\ndefault columns."}],"intent":"Initiate a download of a report in a specified file format","guidance":["Request generation and download channel for the specified report"],"returns":["channel"]},{"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"},{"name":"custom_fields","type":"object","example":{"123":"option_value","456":["multi","select"]},"json_path":"/test_case/custom_fields"},{"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"},{"name":"preconditions","type":"string","json_path":"/test_case/preconditions"},{"name":"priority","type":"integer","example":483,"json_path":"/test_case/priority"},{"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\".","json_path":"/test_case/reviewers"},{"name":"status","type":"integer","example":496,"json_path":"/test_case/status"},{"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":"string","description":"Dataset binding for a data-driven case. On this path it is applied only when `is_dataset_modified` is true.","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"}],"intent":"Partially edit a test case (v2)","guidance":["PREFERRED partial update","send ONLY changed fields","Partially update the details of a test case within a specific folder in a project","it is the authoritative list","send test_case_steps instead - estimate is accepted and then dropped","estimated_duration_seconds is the one that persists"],"returns":["result","step"]},{"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"},{"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"},{"name":"custom_fields","type":"object","json_path":"/test_case/custom_fields"},{"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"},{"name":"name","type":"string","json_path":"/test_case/name"},{"name":"owner","type":"integer","json_path":"/test_case/owner"},{"name":"preconditions","type":"string","json_path":"/test_case/preconditions"},{"name":"priority","type":"integer","json_path":"/test_case/priority"},{"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"},{"name":"tags","type":"array","json_path":"/test_case/tags"},{"name":"template","type":"string","json_path":"/test_case/template"},{"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":"string","description":"Dataset binding for a data-driven case. On create the mappings are always regenerated from this, so `is_dataset_modified` is ignored here.","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","json_path":"/test_case/test_case_steps"}],"intent":"Edit a test case","guidance":["Update the details of a test case within a specific folder in a project","Omitted fields are left untouched EXCEPT template, template_id and review_status, which are reset to defaults"],"returns":["result","step"]},{"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"}],"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],"description":"Explicit case ids to pin into the run. Top level, NOT inside `test_run`. Use this or `selection`."},{"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":"Edit Test Run","guidance":["Update the details of a specific test run within a project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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","pdf","excel"],"example":"csv","description":"Export file format"},{"name":"start_date","type":"string","example":"2025-01-01","json_path":"/filters/start_date"},{"name":"end_date","type":"string","example":"2025-12-31","json_path":"/filters/end_date"},{"name":"status","type":"array","example":["passed","failed"],"json_path":"/filters/status"}],"intent":"Export dashboard analytics data","guidance":["Initiates an asynchronous export of dashboard analytics data in the specified format","The export is processed via background job and returns an export ID for tracking","Async Processing: Data export runs via Sidekiq ExportDashboardWorker::FileExportWorker","Supported Formats: CSV, PDF, Excel (based on type parameter)"],"returns":["export_id"]},{"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":"Get active test run result statistics","guidance":["Retrieve statistics of active test runs for a specific project"],"shape":"discovered"},{"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":"Get archived test cases.","guidance":["Retrieve a list of archived test cases in a specific project"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"Count archived test plans","guidance":["count archived plans","Returns {success, count}","the number of archived plans in the project"],"shape":"discovered"},{"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 automation stats analytics","guidance":["Retrieve automation statistics for a specific project"],"returns":["automated_coverage","automated_test_cases","empty_data","manual_test_cases","total_test_cases"]},{"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":"Get all the closed test runs for a project","guidance":["Retrieve a list of all closed test runs for a specific project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"Get closed test run analytics","guidance":["Retrieve statistics of closed test runs for a specific project"],"returns":["month"]},{"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":"Get closed test runs split analytics","guidance":["Retrieve split statistics of closed test runs for a specific project"],"returns":["name"]},{"method":"GET","path":"/api/v1/projects/{project_id}/configurations","mode":"read","entity":"configuration","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Get configurations for a project","guidance":["Retrieve a list of configurations for a specific project"],"returns":["id","name","created_at","group_id","is_suggested","record_status"]},{"method":"GET","path":"/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets/{dataset_id}","mode":"read","entity":"custom_field","path_params":[{"name":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"Read one dataset \u2014 its project links and option set.","guidance":["read one dataset's project links and options"],"shape":"discovered"},{"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}],"intent":"Read one custom field definition, with its datasets and project links.","guidance":["read one definition + its datasets and linked projects","do this BEFORE any update, which is a full replace","Options are NOT inline","Datasets come back WITHOUT their options inline","Read this before any update","so you need the current values to avoid clearing them"],"shape":"discovered"},{"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},{"name":"entity_type","type":"string","required":true,"values":["test-case","test-result","test-plan"]},{"name":"field_id","type":"string","required":true}],"query":[{"name":"q","type":"string"}],"intent":"Get the allowed values/options of one custom field.","guidance":["If nothing matches, the field doesn't exist in that workspace","say so rather than guessing a field id","Multi-dropdowns need OPTION IDS, not labels","That legacy family writes a table local to the Rails app that is DEPRECATED and that nothing reads","It is blocked at the gate","testhub owns definitions"],"shape":"discovered","paginated":true},{"method":"GET","path":"/api/v1/projects/{project_id}/datasets/{uuid}","mode":"read","entity":"dataset","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"uuid","type":"string","required":true}],"intent":"Get a specific dataset.","guidance":["read one dataset by UUID","Retrieve a specific dataset by its UUID including all variables and rows"],"returns":["name","created_at","rows_count","uuid"]},{"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},{"name":"uuid","type":"string","required":true}],"intent":"Get test cases linked to a dataset","guidance":["list the test cases bound to this dataset (paged p)","Returns the test cases linked to a dataset, identified by its UUID"],"shape":"discovered","paginated":true},{"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"}],"intent":"Get dataset variables/columns.","guidance":["BINDING shape is DIFFERENT from the dataset shape, and this is the usual failure","the dataset's uuid repeated on every column it owns","The binding object has NO top-level uuid or name (both stripped by strong params)","so do NOT copy the dataset's read shape into it","Never report a dataset as linked from the 200 alone","A dataset is addressed by a UUID (the dataset path param), NOT an integer"],"returns":["name","uuid"],"paginated":true},{"method":"GET","path":"/api/v1/projects/{project_id}/ai-duplicates/status","mode":"read","entity":"duplicate","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Dedupe job status \u2014 use this to tell \"feature off\" from \"no duplicates\"","guidance":["ALWAYS call this when the list is empty","Status of the project's most recent dedupe run"],"returns":["status"]},{"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},{"name":"duplicate_id","type":"integer","required":true}],"intent":"Read the pre-merge snapshot of a MERGED duplicate's source test case","guidance":["after a merge, read the pre-merge snapshot of the case that was folded away","After a merge, the source case no longer exists independently","this returns the snapshot captured at merge time","the only way to show the user what was folded away","Only works for status merged (404 otherwise), and the snapshot may legitimately be absent, which also surfaces as a 404 with error: \"Source test case snapshot not available\"","Treat that as \"not retained\", not as an error to retry"],"shape":"discovered"},{"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},{"name":"duplicate_id","type":"integer","required":true}],"intent":"Read one suggested duplicate, with its test cases resolved","guidance":["read one suggestion with its test cases resolved","do this BEFORE merging so the user can see what would be destroyed","Only status=suggested is readable","Full detail for one recommendation, including the actual test cases it links","so you can show the user what would be merged","Only status suggested is readable here"],"shape":"discovered"},{"method":"GET","path":"/api/v1/projects/{project_id}/{entity}/filter-details","mode":"read","entity":"project","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"entity","type":"string","required":true,"values":["test-cases","trtc","test-runs"],"example":"test-cases"}],"query":[{"name":"q[query]","type":"string","example":"login functionality"},{"name":"q[folder_ids]","type":"string","example":"3306878,3669741,5422801,5422802"},{"name":"q[status]","type":"string","example":"9319,9321"},{"name":"q[priority]","type":"string","example":"550376,9261"},{"name":"q[automation_state]","type":"string","example":"18172471,18172473"},{"name":"q[case_type]","type":"string","example":"9379,9375"},{"name":"q[owner]","type":"string","example":"user123,user456"},{"name":"q[assignee]","type":"string","example":"user789,user101"},{"name":"q[tags]","type":"string","example":"regression,smoke"},{"name":"q[created_at]","type":"string","example":"2024-01-01..2024-12-31"},{"name":"q[updated_at]","type":"string","example":"2024-01-01..2024-12-31"}],"intent":"Get filter details for entity","guidance":["Retrieve filter details for a specific entity type (test-cases, trtc, or test-runs) within a project"],"returns":["id","colour","entity_type","field_name","field_value","internal_name"]},{"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":"Get an exploratory session","guidance":["read one session by INTEGER id","Returns a single exploratory session by its ID"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"]},{"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"]},{"name":"start_date","type":"string"},{"name":"end_date","type":"string"},{"name":"session_ids","type":"string"}],"intent":"Exploratory Session Summary \u2014 live view, no saved report needed","guidance":["exploratory-session summary WITHOUT creating a report","Computes an exploratory-session summary on demand","there is no Schedule row behind it","Use it to answer \"summarise our exploratory sessions\" without creating a report first","Returns session counts, bugs found, top testers (resolved to user objects) and the session list"],"shape":"discovered"},{"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":"Get filter details","guidance":["Retrieve details of a specific saved filter by its ID","It does NOT validate or strip invalid custom-field references","a filter saved with a non-existent custom-field id is returned unchanged","A missing or deleted filter comes back as 400 {\"success\": false, \"message\": \"Filter not found\"}, not 404"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"]},{"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":"Get summary of entities to be deleted when removing a folder","guidance":["PREVIEW what deleting a folder will remove before calling rm","Provides a summary of all entities (test cases, recordings, sub-folders) that would be deleted if the folder is removed"],"returns":["active_test_case_count","archived_test_case_count","sub_folders","test_recordings_count"]},{"method":"GET","path":"/api/v1/projects/{project_id}/repository/mapping","mode":"read","entity":"folder","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Get the project's folder tree (v1).","guidance":["the whole project folder TREE in one call (structure array)"],"shape":"discovered"},{"method":"GET","path":"/api/v1/group/users-v2","mode":"read","entity":"user","query":[{"name":"q","type":"string"}],"intent":"Get group users V2 with pagination","guidance":["Retrieve a paginated list of users in the group with search and filtering capabilities, WITHOUT project scoping","used by the Global Search filter dropdowns","Search by user IDs (comma-separated) or by a name string"],"returns":["id","browserstack_user_id","email","full_name","group_id","onboarded","reports_and_notification_enabled"]},{"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":"Get issues count analytics","guidance":["Retrieve the count of issues for a specific project"],"returns":["label"]},{"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","guidance":["read ONE project by its INTEGER id","so for an integer id this is the right read","Do NOT translate first just to fetch the project","It serves two jobs at once: (1) READ","returns the project's full detail (name, description, permissions, \u2026)","In other words it is NOT translation-only"],"returns":["id","identifier","name","created_at","description","jira_mapped","starred","test_cases_count","test_plans_count","test_runs_count","thProjectId","user_role"]},{"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}],"intent":"Get project-specific custom fields V2 for test cases","guidance":["START HERE for CUSTOM fields","Does NOT expose system-field option ids","Retrieve custom fields, default fields, and system fields for test cases in a specific project (V2 format)"],"returns":["id","default_value","entity_type","field_name","field_type","group_id","is_bulk_editable","is_filterable","is_required","linked_projects_count","place_holder_text","system_name"]},{"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"]}],"intent":"Get project settings","guidance":["Returns an array of enabled setting keys"],"returns":["in_review_count","test_plan_in_review_count"]},{"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"}],"intent":"Get project users V2 with pagination","guidance":["Retrieve paginated list of users in the group with search and filtering capabilities","Search by user IDs (comma-separated) or by name string"],"returns":["id","browserstack_user_id","email","full_name","group_id","onboarded","reports_and_notification_enabled"]},{"method":"GET","path":"/api/v1/projects/basic","mode":"read","entity":"project","query":[{"name":"q[query]","type":"string","example":"nishchay3"},{"name":"q[identifier]","type":"string","example":"PR-60863"}],"intent":"Get paginated projects (lean payload) \u2014 use this to list or count projects","guidance":["LIST or COUNT projects","so you can reach a true total without truncating","Those two are the only recognised keys"],"returns":["id","name","description","import_id","normalisedName","starred","thProjectId"],"paginated":true},{"method":"GET","path":"/api/v1/projects/minify","mode":"read","entity":"project","intent":"Get all projects (minified dropdown) \u2014 has NO name search","guidance":["never use it to resolve a named project or to count them","Minified list of active projects, for dropdown-style selection","It accepts no q in any form","the backend call has no query option","so it can only page through everything","Do NOT use it to resolve a project the user named"],"returns":["id","name","description","import_id","normalisedName","starred","thProjectId"],"paginated":true},{"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},{"name":"attachment_id","type":"integer","required":true}],"intent":"Get download URL for a report attachment","guidance":["Retrieve a secure URL to download a specific report attachment"],"returns":["url"]},{"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"},{"name":"report_data_only","type":"boolean"},{"name":"today","type":"string","example":"2025-05-13"}],"intent":"Retrieve a scheduled report's config and data \u2014 the general report read","guidance":["works for every report type","Full configuration plus computed data for a report of ANY type","request the section and look at the result","which is a real finding","Pass sections to compute the sections you need","several in ONE call, cheaper than one request per section"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"],"paginated":true},{"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"}],"query":[{"name":"widget_type","type":"string"},{"name":"segment","type":"string"}],"intent":"Fetch one report widget/section \u2014 works for EVERY report type.","guidance":["Section names are validated per type","*_drilldown sections return paginated ROWS, not aggregates","Returns a single section's data for any report type","This is the general section read","so when you need SEVERAL sections, prefer that form with a comma-separated sections list and take them in one call rather than one request per section","Most sections return the computed aggregate for a widget"],"shape":"discovered","paginated":true},{"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","requirement_traceability_report"]}],"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":"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`"},{"name":"sections","type":"string","example":"test_run_data,test_run_status"},{"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"]},{"name":"segment","type":"string"}],"intent":"Get summary statistics for a scheduled report \u2014 run and traceability types ONLY","guidance":["400s on every other type","so not for Test Case Activity or Test Plan Summary","Summary statistics for a scheduled report, for THREE report types only","including every other type in ReportType","This is not the general report-read","optionally with sections"],"shape":"discovered","paginated":true},{"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 of the folder to search for"},{"name":"include_folder_hierarchy","type":"boolean","description":"Whether to include folder hierarchy in the response"}],"intent":"Get all folders and subfolders present in the projects.","guidance":["list root folders (paged with p","Returns all folders and subfolders in a project if it exists in TestHub"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"],"paginated":true},{"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":"Get already selected testcases for a tracebility report","guidance":["Retrieve the already selected testcases for a tracebility report"],"returns":["select_all","unique_test_case_count"]},{"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":"paginated","type":"boolean"}],"intent":"Get shared steps","guidance":["read one shared step with its ordered step details","Retrieves a list of shared steps for a project"],"returns":["id","title"],"paginated":true},{"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":"q[query]","type":"string"},{"name":"pre_fetch","type":"boolean"}],"intent":"Get all shared steps for a project","guidance":["Returns a list of all shared steps within a project"],"returns":["id","title","step_count","test_case_count"],"paginated":true},{"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"}],"intent":"THE source for a system field's option ids (priority/status/case_type/automation_state).","guidance":["a single attachment-URL field is enough to push the response past the ~30 KB cap","Supports q to search the option list and p to page it, which matters","a workspace can accumulate dozens of options on a single field"],"shape":"discovered","paginated":true},{"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":"Get all attachments for a test case in a folder in a project","guidance":["list a case's attachments","Retrieve all attachments associated with a specific test case within a folder","CURRENTLY RETURNING 500","Verified live against five different real test cases, with both the case's true folder_id and a mismatched one","a reproducible server error, not a bad request","Do NOT retry it and do not report it to the user as their mistake"],"returns":["id","byte_size","content_type","filename"]},{"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":"Get a test case by INTEGER id (v1, folder-scoped) \u2014 full detail + its TC-NNN identifier","guidance":["READ one case by INTEGER id (folder-scoped)","Read a test case by its INTEGER id","so for an integer id this v1 read is the ONLY way to fetch the case","Two jobs at once: (1) READ","NOT translation-only","Don't fall back to listing the folder to find the case: if you have the integer id, read it here directly"],"returns":["id","identifier","title"]},{"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":"Get test case count trend analytics","guidance":["Retrieve the trend of test case counts for a specific project"],"returns":["field"]},{"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","example":"folder_path"}],"intent":"Get Test Case Details","guidance":["full detail (steps, custom fields, issues) before editing","read this, improve, then write back"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"Get test case histories","guidance":["list all version records for a test case","Retrieve all history records for a specific test case"],"returns":["id","comment","created_at","entity_id","entity_type","group_id","project_id","source","user_id","version_id","version_name"]},{"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}],"intent":"Get test case history diff","guidance":["compare two versions","versions[] with exactly two ids (PAID plan)","Retrieve the diff of a specific history record for a test case"],"returns":["id","order","result","shared_step_id","step"]},{"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":"Get a specific test case history","guidance":["Retrieve details of a specific test case history by its ID"],"returns":["id","comment","created_at","entity_id","entity_type","group_id","project_id","source","user_id","version_id","version_name"]},{"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":"Get tags and metadata for a test case","guidance":["read a case's current tags","Retrieve the tags and metadata associated with a specific test case in a project"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"Get test case type split analytics","guidance":["Retrieve the split of test case types for a specific project"],"returns":["name","field","value","y"]},{"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"}],"query":[{"name":"required[fields]","type":"string","values":["count"]}],"intent":"Get paginated test cases for a test run","guidance":["page the cases in a run (each row is the case you log results against)","Retrieve a paginated list of test cases for a specific test run in a project"],"shape":"discovered"},{"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":"required[fields]","type":"string","example":"owner,priority"},{"name":"required[custom_fields]","type":"string","example":"121,119"},{"name":"all_folders","type":"string","values":["true"],"example":"true"}],"intent":"List a project's test cases \u2014 REQUIRES all_folders=true to be project-wide.","guidance":["Without all_folders=true this returns only ONE folder's cases","the project's first root folder","So a project-wide question answered from a bare call silently under-counts by whatever sits in every other folder, and nothing in the response says so","all_folders is compared as the STRING 'true' (all_folders = params['all_folders'] == 'true')","A JSON boolean true, 1, TRUE or any other value all fail that check and silently fall back to the single-folder scope"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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":"Get a test plan by INTEGER id (v1) \u2014 full detail + its TP-NNN identifier","guidance":["READ one plan by INTEGER id","Read a test plan by its INTEGER id (project integer id in the path)","Don't translate first just to read the plan","this returns its full detail directly","Two jobs at once: (1) READ","the plan's full detail (under test_plan"],"returns":["identifier","name"]},{"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"},{"name":"test_run_id","type":"integer"},{"name":"today","type":"string"}],"intent":"Execution-trend chart data for ONE plan or ONE run (XOR)","guidance":["execution-trend chart data for ONE plan or ONE run","pass test_plan_id XOR test_run_id (both or neither is a 400)","Time-series execution trend backing the Insights tab chart","Scope it with exactly one of test_plan_id or test_run_id","Passing both returns 400 \"Provide either test_plan_id or test_run_id, not both\"","passing neither returns 400 \"test_plan_id or test_run_id is required\""],"shape":"discovered"},{"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 a test plan","guidance":["chart data for the exploratory sessions linked to a plan","Aggregated chart data for exploratory sessions linked to the plan","the other half of the Insights tab","Like execution-trend, this is chart data only","insights widgets themselves are not a CRUD resource here"],"shape":"discovered"},{"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":"include_sub_test_plan_runs","type":"boolean"},{"name":"compact","type":"boolean"},{"name":"is_archived","type":"boolean"}],"intent":"List the test runs linked to a test plan","guidance":["list the runs a plan groups","Paginated list of the runs a plan groups","that field replaces the link set rather than appending to it","include_sub_test_plan_runs=true also returns runs belonging to the plan's sub-plans"],"shape":"discovered","paginated":true},{"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}],"intent":"Get paginated custom fields for test results","guidance":["Retrieve paginated list of custom fields configured for test results in a project"],"returns":["id","default_value","entity_type","field_name","field_type","field_user_name","is_bulk_editable","is_filterable","is_required","optional","placeholder"],"paginated":true},{"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"},{"name":"test_case_id","type":"integer","required":true}],"intent":"Get test results for a test case in a test run","guidance":["read a case's results within a run","Retrieve a list of test results for a specific test case within a test run in a project"],"returns":["id","status","backtrace","configuration_id","created_at","created_by_imported","custom_fields","description","error_description","expanded","failure_type","finished_at"]},{"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":["READ a run by INTEGER id","Read a test run by its INTEGER id (with the project's integer id in the path)","Don't translate first just to read the run","this returns its full detail directly","Two jobs at once: (1) READ","NOT translation-only"],"returns":["id","identifier","name","uuid"]},{"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},{"name":"test_run_id","type":"string","required":true}],"intent":"Get a run with its per-case rows (v1).","guidance":["the run with its per-case rows (did it pass?)"],"shape":"discovered"},{"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"}],"intent":"Get custom field values for test results in test runs","guidance":["Retrieve default field values and optionally custom fields for test results (TRTC - Test Run Test Case)"],"returns":["id","applies_to_all_projects","default_value","entity_type","field_name","field_type","is_required","link_to_future_projects","place_holder_text"]},{"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.","json_path":"/selection/select_all"},{"name":"unique_test_case_count","type":"integer","example":2,"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.","json_path":"/selection/folders"},{"name":"shared_selection","type":"object"}],"intent":"get selected test runs of a project","guidance":["Retrieve selected test runs for a specific project based on the provided selection criteria"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"ignore_run_type","type":"boolean"},{"name":"include_test_plan","type":"boolean"}],"intent":"Get all the test runs for a project","guidance":["list the project's (open) runs, paged with p","Retrieve a list of all test runs for a specific project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"Import a CSV dataset \u2014 NOT SUPPORTED in this profile","guidance":["NOT SUPPORTED in this profile","say CSV import isn't available","CSV import is not supported here","If asked to import a dataset from CSV, say it isn't available and point to the Test Management UI"]},{"method":"GET","path":"/api/v1/activities","mode":"read","entity":"activity","query":[{"name":"project_id","type":"integer"},{"name":"cursor","type":"string"},{"name":"limit","type":"integer"},{"name":"actor_id","type":"integer"},{"name":"starred_only","type":"boolean"},{"name":"entity_type","type":"array"}],"intent":"Read the activity feed (audit trail) \u2014 CURSOR paged, 15-day window","guidance":["WHO changed WHAT recently","group-wide audit trail","Group-wide audit trail of who changed what","Read-only: activity events cannot be created, edited, or deleted","Two things differ from every other list in this profile: - Cursor pagination, not page numbers","so you cannot jump to a page or read a total"],"returns":["no_starred_projects"]},{"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}],"intent":"List archived test plans","guidance":["where a 'missing' plan usually is, and the source of ids for bulk-retrieve","Paginated list of the project's archived plans","so if a plan the user names seems missing, look here before concluding it was deleted"],"shape":"discovered","paginated":true},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"List a dataset's options with their ids.","guidance":["list a dataset's options WITH their ids","the ids a filter or a test-case write needs"],"shape":"discovered"},{"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"},{"name":"q[uuid]","type":"string"}],"intent":"List datasets for a project.","guidance":["list datasets (paged p","Retrieve a paginated list of datasets for a project with optional filtering by name or UUID"],"returns":["name","created_at","rows_count","test_cases_count","updated_at","uuid"],"paginated":true},{"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":"entity_type","type":"string"},{"name":"entity_id","type":"integer"},{"name":"duplicates","type":"string"}],"intent":"List AI-suggested duplicate test cases \u2014 an empty list may mean the feature is OFF","guidance":["LIST the duplicate suggestions awaiting review","An EMPTY list may mean the feature is off","List the AI dedupe recommendations awaiting review in a project","Only duplicates with status suggested are returned","once merged, archived, or discarded they leave this list","An empty result is ambiguous"],"shape":"discovered","paginated":true},{"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}],"intent":"List defects for an exploratory session","guidance":["List defaults to active sessions","the parent project takes the INTEGER project id (v1)","defects are the issues linked to the session"],"returns":["issue_id","issue_type"],"paginated":true},{"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}],"intent":"List logs for an exploratory session","guidance":["page the session's log entries","Returns a paginated list of log entries for the specified exploratory session"],"returns":["id","status","content","created_at","elapsed_time","session_id","updated_at"],"paginated":true},{"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":"q[session_state]","type":"string","values":["active","closed"],"example":"active"},{"name":"q[assignee]","type":"integer","example":42},{"name":"q[owner]","type":"integer","example":42},{"name":"q[created_by]","type":"integer","example":10},{"name":"q[created_at_from]","type":"string","example":"2026-01-01"},{"name":"q[created_at_to]","type":"string","example":"2026-01-31"},{"name":"q[tags][]","type":"array","example":["regression","payment"]},{"name":"q[configuration_ids][]","type":"array","example":[1,2]},{"name":"q[search]","type":"string","example":"checkout"},{"name":"q[status]","type":"string","example":"pass"}],"intent":"List exploratory sessions for a project","guidance":["list sessions (defaults to active","Returns a paginated list of exploratory sessions"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"],"paginated":true},{"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","type":"string","required":true,"values":["testcase","testplan","testrun","exploratory_session","test_plan_test_case"]}],"intent":"List filters","guidance":["list saved filter views (optional entity = testcase|testplan|testrun|exploratory_session)","Retrieve a paginated list of saved filters for a project, optionally filtered by entity type"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"],"paginated":true},{"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":"List subfolders and folder metadata for a specific folder in a project","guidance":["one folder's detail + its direct subfolders + ancestors","Retrieve a list of subfolders and their metadata for a specific folder in a project"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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":"required[fields]","type":"string","example":"owner,priority"},{"name":"required[custom_fields]","type":"string","example":"121,119"}],"intent":"List the test cases in one folder","guidance":["Page through the cases directly inside a folder, by the folder's INTEGER id","Use this when the user has already named or selected a folder","Rows are verbose and list reads truncate around 30 KB"],"shape":"discovered","paginated":true},{"method":"GET","path":"/api/v1/projects","mode":"read","entity":"project","query":[{"name":"q","type":"string","example":"nishchay3"},{"name":"sort_by","type":"string"},{"name":"starred","type":"boolean"},{"name":"shared","type":"boolean"},{"name":"is_hidden_projects","type":"boolean","example":true}],"intent":"List projects for a group.","guidance":["Paginated list of the group's projects","query and page are not read by the server","so use this to FIND a project, not to enumerate them"],"returns":["id","identifier","name","created_at","description","import_id","project_creator","test_cases_count","test_runs_count"],"paginated":true},{"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"},{"name":"q[scheduled]","type":"string","values":["true","false"]}],"intent":"List all the scheduled reports","guidance":["list the project's report schedules (paged p","Retrieve a paginated list of schedules"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"],"paginated":true},{"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":"q","type":"string"}],"intent":"List test-case tags (paginated).","guidance":["list a project's test-case tags (paged with p, optional q)"],"shape":"discovered","paginated":true},{"method":"GET","path":"/api/v1/admin-v2/settings/templates","mode":"read","entity":"","query":[{"name":"entity_type","type":"string","values":["TestCase","TestResult","TestPlan","ExploratorySession"]},{"name":"project","type":"integer"},{"name":"name","type":"string"}],"intent":"List the workspace's test-case templates \u2014 THE source for `template_id`.","guidance":["Ids are per-workspace","so one carried over from another workspace (or from an example) produces the generic 400 \"The API request is invalid or improperly formed\" on a test-case create, with nothing naming the offending field","Call this when the user asked for a named template, or to confirm a template id before reusing one","One call therefore yields both fields","NOTE THE CASING: entity_type here is CamelCase (TestCase), unlike the hyphenated test-case used in path segments elsewhere","Passing test-case returns an empty list with a 200"],"shape":"discovered","paginated":true},{"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":"include_sub_plans","type":"boolean"},{"name":"status","type":"string","values":["new","started","completed"]},{"name":"sort_by","type":"string"},{"name":"minify","type":"boolean"}],"intent":"List test plans in a project (optionally including sub-plans)","guidance":["include_sub_plans=true to see sub-plans too","Paginated list of the project's test plans","This is how you find a plan's integer id before reading, updating, or deleting it","so never try to find a plan through search","Without it you see only top-level plans and a plan's children are invisible"],"shape":"discovered","paginated":true},{"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"]},{"name":"field_source","type":"string","values":["system","custom","all"]},{"name":"field_type","type":"string"},{"name":"q","type":"string"}],"intent":"THE canonical workspace field list (system + custom) \u2014 start here for definitions.","guidance":["workspace-wide list of DEFINITIONS across all projects","'does a field called X exist anywhere?'","The authoritative list (testhub-backed)","This is the authoritative definition list: it is backed by testhub, which owns custom-field definitions","That legacy collection reads a DEPRECATED table local to the Rails app that nothing else consults","its contents are unrelated to the real fields"],"shape":"discovered","paginated":true},{"method":"POST","path":"/api/v1/projects/{project_id}/tags/merge","mode":"write","entity":"tag","path_params":[{"name":"project_id","type":"integer","required":true}],"body":[{"name":"source_id","type":"integer","required":true},{"name":"target_id","type":"integer","required":true},{"name":"entity_type","type":"string","required":true,"values":["test_case","test_run","test_plan","shared_step"]}],"intent":"Merge one tag into another (v1). Admin-gated (tags_management).","guidance":["merge one tag into another ({ source_id, target_id, entity_type })"]},{"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","required":true,"example":123,"description":"ID of the new parent folder (internal APIs)"},{"name":"new_base_project_id","type":"integer","example":456,"description":"Optional destination project ID for cross-project moves"},{"name":"user_action","type":"string","example":"move_folder"}],"intent":"Move a test case folder to a different parent folder","guidance":["move a folder ({ new_base_folder_id, new_base_project_id })","cross-project moves run async","Move a test case folder to a new parent folder within the same project","This operation updates the folder's hierarchy without changing its contents"],"returns":["async","unique_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","required":true,"description":"Name of the new folder.","json_path":"/folder/name"},{"name":"notes","type":"string","description":"Description of the new folder.","json_path":"/folder/notes"}],"intent":"rename a folder in a project","guidance":["Rename an existing folder within a specific project"],"returns":["request_trace_id"]},{"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],"description":"List of folder IDs to reorder"},{"name":"prev","type":"integer","example":101,"description":"ID of the folder that will precede the moved folder"},{"name":"next","type":"integer","example":103,"description":"ID of the folder that will follow the moved folder"}],"intent":"Reorder folders in a project","guidance":["Reorder folders within a project by specifying the current order and optionally the previous and next folder IDs"],"returns":["request_trace_id"]},{"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":"ID of the test case that will be above the moved ones.","json_path":"/re_order/top_test_case"},{"name":"bottom_test_case","type":"string","description":"ID of the test case that will be below the moved ones.","json_path":"/re_order/bottom_test_case"},{"name":"test_cases","type":"array","required":true,"description":"Array of test case IDs to be moved.","json_path":"/re_order/test_cases"}],"intent":"Reorder test cases within a folder of a project","guidance":["reorder cases within a folder","Reorders test cases in a folder by placing them between top_test_case and bottom_test_case"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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. Passed in the BODY, not the path."},{"name":"merge_action","type":"string","required":true,"description":"`merge` folds source into target; any other value archives."},{"name":"source_testcase_id","type":"integer","description":"Required for merge \u2014 the case that will cease to exist independently."},{"name":"target_testcase_id","type":"integer","description":"Required for merge \u2014 the case that survives."}],"intent":"Resolve a duplicate by MERGING or ARCHIVING \u2014 destroys or hides a test case","guidance":["RESOLVE a suggestion","merge_action=merge folds source_testcase_id into target_testcase_id (DESTRUCTIVE to a test case), anything else archives","duplicate_id goes in the BODY","Act on one dedupe recommendation","merge_action selects what happens: - \"merge\"","folds source_testcase_id into target_testcase_id"]},{"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":"Restore a specific history entry","guidance":["roll the case back to this version (PAID plan","Restore a specific history entry by its ID"]},{"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":"duplicates","type":"string","description":"Duplicate-type filter."},{"name":"owner","type":"string","description":"Owner tab \u2014 scopes to the caller's own duplicates vs all."}],"intent":"Filtered search over suggested duplicates","guidance":["filtered search over suggestions (owner tab, duplicate type, test-case attributes)","Subject to the identical flag caveat: an empty list may mean the feature is off rather than that there are no duplicates"],"shape":"discovered","paginated":true},{"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"}],"query":[{"name":"q","type":"string"}],"intent":"Search group tags for an entity type","guidance":["search tags across the whole group (entity_type = test-case|test-run|test-plan)","Retrieve a paginated list of tags for the given entity type across the group (no project scoping)","used by the Global Search filter dropdowns"],"returns":["name","created_at","usage_count"],"paginated":true},{"method":"GET","path":"/api/v1/projects/{project_id}/{entity}/search","mode":"read","entity":"project","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"entity","type":"string","required":true,"values":["test-cases","test-runs","test-plans","reports"]}],"query":[{"name":"q[query]","type":"string","example":"tc"},{"name":"q[folder_id]","type":"integer","example":29529465},{"name":"use_bstack_id","type":"string","values":["0","1"]},{"name":"include_test_plan","type":"boolean"}],"intent":"Search for entities within a project","guidance":["Returns paginated results matching the search criteria"],"shape":"discovered","paginated":true},{"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":"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":["Note: Array values in the q parameter are automatically converted to comma-separated strings for compatibility with existing search logic"],"shape":"discovered","paginated":true},{"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"}],"intent":"Search users with access to a project","guidance":["Retrieves a paginated list of users who have access to the specified project","Returns user details including permissions, feature flags, and product roles"],"returns":["id","browserstack_user_id","customisable_dashboard_accessible","full_name","group_id","has_access","is_build_listing_enabled","is_delighted_visible","is_jira_app_project_panel_visible","is_lcnc_explore_dismissed","is_new_project_settings_visible","is_onboarding_project_header_visible"],"paginated":true},{"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"}],"intent":"Search test case tags","guidance":["Retrieves a paginated list of tags associated with test cases in the project","Returns both simple tag strings and detailed tag objects with metadata"],"returns":["name","created_at","usage_count"],"paginated":true},{"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":"query","type":"string"},{"name":"use_bstack_id","type":"string","values":["0","1"]}],"intent":"Search test cases in a project","guidance":["and see pagination-and-filtering for the key table and each value's source","there is NO top-level values array, and looking for one finds nothing","so it is routinely cut off and appears absent","NEVER probe candidate integers for an id","The four system option fields","a name silently returns 0 because the server matches an id column"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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","example":["qa-leads@company.com"],"description":"Recipient addresses. MUST be an array, even for one address."},{"name":"file_type","type":"array","example":["pdf"],"description":"Formats to attach. MUST be an array. Defaults to [\"pdf\"]."}],"intent":"Email a report immediately (async job).","guidance":["email the report immediately (async job)","Kicks off a background send","a 200 means the job was queued, NOT that mail was delivered","don't report it as sent","Both body fields are ARRAYS and are rejected with 400 \" should be an array\" if sent as scalars","Omitting emails sends to the report's configured recipients"]},{"method":"POST","path":"/api/v1/projects/{project_id}/folder/{folder_id}/undo-rm","mode":"write","entity":"folder","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"folder_id","type":"integer","required":true}],"intent":"Undo folder deletion","guidance":["Restores a previously deleted folder and returns its metadata along with path hierarchy"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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":"dataset_id","type":"integer","required":true},{"name":"option_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"body":[{"name":"option_value","type":"string","required":true,"description":"The option label."},{"name":"is_default","type":"boolean","required":true,"description":"REQUIRED \u2014 a missing value is a 400. Must be 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 parent option this hangs under."}],"intent":"Rename an option or change its default (POST to /update).","guidance":["NON-destructive, stored values follow the option id","Both option_value and is_default are required","a missing is_default is a 400","Renaming an option keeps the stored values pointing at it (the option id is unchanged)","so this is the NON-destructive way to fix a label"]},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"body":[{"name":"link_projects","type":"array","description":"Project INTEGER ids to ADD. Note the spelling \u2014 not `linked_projects`."},{"name":"unlink_projects","type":"array","description":"Project INTEGER ids to REMOVE. Destroys their stored values."},{"name":"link_to_future_projects","type":"boolean"},{"name":"select_all","type":"boolean","description":"Apply to every project; may switch the call to async."}],"intent":"Link or unlink projects for a dataset \u2014 how you change which projects see a field.","guidance":["CHANGE WHICH PROJECTS","Unlinking destroys that project's values","The keys here are link_projects and unlink_projects","Send the wrong spelling and it is silently dropped: 200, nothing changed","This is the single easiest mistake on this surface","These are DELTAS, not the complete new list: send only the projects to add and the projects to remove"]},{"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,"description":"Display label. Editable. REQUIRED even when unchanged."},{"name":"field_type","type":"string","required":true,"description":"REQUIRED for validation, but any change is silently ignored."},{"name":"is_required","type":"boolean","required":true,"description":"REQUIRED \u2014 omitting it is a 400."},{"name":"entity_type","type":"string"},{"name":"place_holder_text","type":"string"},{"name":"default_value","type":"string","description":"Only applied when `field_type` is `boolean`."}],"intent":"Update a field definition's label, requiredness or placeholder (POST to /update).","guidance":["Full replace: field_name + field_type + is_required all required","field_type changes are silently ignored","field_name, field_type and is_required must all be present","Omitting is_required is 400 \"Invalid Params\"","omitting field_name is a 500","field_name IS editable here"]},{"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","description":"Updated name of the dataset.","json_path":"/dataset/name"},{"name":"variables","type":"array","description":"Updated list of dataset variables/columns (max 40).","fields":[{"name":"name","type":"string","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/variables"},{"name":"rows","type":"array","description":"Updated list of dataset rows (max 100).","fields":[{"name":"row_number","type":"integer","required":true},{"name":"row_data","type":"array","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/rows"}],"intent":"Update a dataset.","guidance":["Update an existing dataset by its UUID with new variables and rows"],"returns":["name","created_at","rows_count","uuid"]},{"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 for the session.","json_path":"/exploratory_session/title"},{"name":"description","type":"string","description":"Updated description.","json_path":"/exploratory_session/description"},{"name":"charter","type":"string","description":"Updated testing charter.","json_path":"/exploratory_session/charter"},{"name":"timebox_duration","type":"integer","example":90,"description":"Updated planned duration in minutes.","json_path":"/exploratory_session/timebox_duration"},{"name":"duration","type":"integer","example":45,"description":"Tracked duration in minutes.","json_path":"/exploratory_session/duration"},{"name":"actual_duration","type":"integer","example":50,"description":"Final actual duration in minutes.","json_path":"/exploratory_session/actual_duration"},{"name":"folder_id","type":"integer","example":789,"description":"Move the session to a different folder.","json_path":"/exploratory_session/folder_id"},{"name":"owner","type":"integer","example":55,"description":"TCM user ID of the new owner / assignee.","json_path":"/exploratory_session/owner"},{"name":"assignee","type":"integer","example":55,"description":"Alias for `owner`.","json_path":"/exploratory_session/assignee"},{"name":"tags","type":"array","example":["smoke","payment"],"description":"Replace the session's tags with this list.","json_path":"/exploratory_session/tags"},{"name":"configurations","type":"array","example":[2,4],"description":"Replace the session's configuration IDs.","json_path":"/exploratory_session/configurations"},{"name":"attachments","type":"array","description":"Updated set of blob / media attachment IDs.","json_path":"/exploratory_session/attachments"},{"name":"issues","type":"array","description":"Replace the linked issues for this session.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/exploratory_session/issues"}],"intent":"Update an exploratory session","guidance":["edit a session (title, charter, timebox_duration, duration, owner, tags, ...)","Updates one or more fields of an exploratory session","Time fields (timebox_duration, duration, actual_duration) must be non-negative integers not exceeding 2147483647"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"]},{"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":"Updated rich-text HTML content of the log entry.","json_path":"/session_log/content"},{"name":"status","type":"string","values":["pass","fail","bug","retest","block","note"],"example":"fail","description":"Updated test result status.","json_path":"/session_log/status"},{"name":"elapsed_time","type":"integer","example":180,"description":"Updated elapsed time in seconds.","json_path":"/session_log/elapsed_time"},{"name":"defects","type":"array","description":"Replace the linked defects for this log entry.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/session_log/defects"}],"intent":"Update a session log entry","guidance":["Updates an existing log entry within an exploratory session","Rich-text HTML content is sanitised before storage"],"returns":["id","status","content","created_at","elapsed_time","session_id","updated_at"]},{"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,"description":"Name of the filter"},{"name":"entity","type":"string","required":true,"values":["testcase","testplan","testrun","exploratory_session","test_plan_test_case"],"description":"Entity type the filter applies to. The server's validator accepts five values (the\nlast two were missing here); an unlisted value returns 400 \"Invalid JSON data\"."},{"name":"is_project_level","type":"boolean","description":"Whether this filter is available at project level"},{"name":"filters","type":"object","description":"Filter criteria object containing the actual filter conditions"}],"intent":"Update a filter","guidance":["Update an existing saved filter with new configuration or filter criteria"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"]},{"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"},{"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 these are ids, not names).","json_path":"/test_result/status_id"},{"name":"description","type":"string","description":"Rich text description of the test result.","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's 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":"Update the latest test result","guidance":["update the LATEST result for a case in a run (partial, same body shape)","Update the most recent test result for a specific test case within a test run in a project","Optionally filter by configuration_id or mapping_id"],"returns":["id","name","byte_size","checksum","content_type","download_url","key","product_id","size","url"]},{"method":"POST","path":"/api/v1/projects/{project_id}/settings","mode":"write","entity":"project","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Update project settings","guidance":["Accepts an object with setting keys as properties and boolean values"]},{"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","example":10000},{"name":"report_type","type":"string","required":true,"values":["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","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_statu"},{"name":"title","type":"string","example":"Updated Title"},{"name":"description","type":"string","example":""},{"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 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`"},{"name":"report_creation_mode","type":"string","required":true,"values":["custom_report","system_generated"],"example":"custom_report"},{"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"},{"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":"Integer run ids \u2014 pairs with report_timeframe=specific_test_runs.","json_path":"/report_filters/test_runs"},{"name":"test_plans","type":"array","description":"Integer plan ids \u2014 pairs with report_timeframe=specific_test_plans.","json_path":"/report_filters/test_plans"},{"name":"test_cases","type":"string","description":"Case selection \u2014 pairs with report_timeframe=specific_test_cases. FOLDER-KEYED\n(see SelectionData), never a flat id list: the server resolves the selection\nthrough its folder map, so any other shape yields zero cases and saves an\nUNSCOPED, project-wide report at 200.\n\nSend all three keys. Folder ids are STRING keys; test-case ids are INTEGERS\n(the numeric `id`, not the TC-NNN identifier) \u2014 take bo","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":"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.","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 } }.","json_path":"/report_filters/test_plan_selection"},{"name":"builds","type":"array","json_path":"/report_filters/builds"},{"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\n`Test Run Summary`, `Test Run Detailed Report` and `Test Case Activity` \u2014 on\nthe other six report types the server never reads it and drops it silently.\nPersisted (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`."},{"name":"users","type":"array","json_path":"/mail_to/users"},{"name":"external_mails","type":"array","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\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"},{"name":"time","type":"string","example":"08:00","description":"24-hour HH:MM, UTC.","json_path":"/frequency_details/time"},{"name":"day","type":"string","example":"monday","description":"Required for weekly (lowercase day name) and monthly (integer 1-28).\nOmit for daily.","json_path":"/frequency_details/day"}],"intent":"Update a scheduled report","guidance":["FULL REPLACE, not a delta: omitted fields are nulled and dropping frequency cancels the schedule","Update the configuration of an existing scheduled report for a project"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"]},{"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":"Title of the shared step"},{"name":"folder_id","type":"integer","description":"ID of the shared-field folder to move the shared step into. Null moves it to the root (Unassigned)."},{"name":"shared_step_details","type":"array","required":true,"fields":[{"name":"id","type":"integer"},{"name":"step","type":"string"},{"name":"result","type":"string"},{"name":"order","type":"integer"},{"name":"test_data","type":"string"}]}],"intent":"Update a shared step","guidance":["update a shared step","title and shared_step_details are BOTH required, and the details array REPLACES the existing steps","Updates an existing shared step in a project"],"returns":["id","title"]},{"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"},{"name":"description","type":"string","json_path":"/test_plan/description"},{"name":"start_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/start_date"},{"name":"end_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/end_date"},{"name":"plan_status","type":"string","description":"Plan status. The list filter accepts new / started / completed.","json_path":"/test_plan/plan_status"},{"name":"owner","type":"integer","description":"Owner user id \u2014 resolve via the project users read first.","json_path":"/test_plan/owner"},{"name":"parent_plan_id","type":"integer","description":"Parent plan's INTEGER id. Set it to create (or re-parent into) a SUB-plan \u2014 the\nresult carries an STP-NNN identifier. Null/omitted means a top-level plan.","json_path":"/test_plan/parent_plan_id"},{"name":"tags","type":"array","json_path":"/test_plan/tags"},{"name":"reviewers","type":"array","description":"Reviewer user ids.","json_path":"/test_plan/reviewers"},{"name":"attachments","type":"array","description":"Attachment ids already uploaded via the attachments surface.","json_path":"/test_plan/attachments"},{"name":"custom_fields","type":"object","json_path":"/test_plan/custom_fields"},{"name":"issues","type":"array","description":"Linked tracker issues. Structured \u2014 NOT a flat array of keys.","fields":[{"name":"issue_id","type":"string"},{"name":"issue_type","type":"string"},{"name":"metadata","type":"object"}],"json_path":"/test_plan/issues"},{"name":"test_runs","type":"string","description":"The plan's linked test runs. Accepts EITHER an array of test-run integer ids, OR a\nbulk-selection object for \"every run matching this filter\". On update this REPLACES\nthe existing link set rather than appending.","json_path":"/test_plan/test_runs"}],"intent":"Update a test plan (or sub-plan)","guidance":["Update a plan by its INTEGER id","Takes the same body as create","so it also works on a sub-plan by its own id","clearing it promotes a sub-plan to a top-level plan","do that only when the user actually asked to move it in the hierarchy","Send only the fields you intend to change"]},{"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 AI-generated or AI-processed attachments","guidance":["upload a file as AI CONTEXT (restricted MIME types, smaller size cap) to seed test-case content","Files are stored in S3 with pre-signed URLs","File Storage: Files uploaded to S3 with pre-signed URLs (expires in ~26 minutes)"],"returns":["id","name","content_type","download_url","size","url"]},{"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 generic attachments to a project from rich text editor or other sources","guidance":["UPLOAD file blob(s) (multipart)","Uploads one or more files as generic attachments to a project","Files are stored in S3 and pre-signed URLs are returned for both viewing and downloading"],"returns":["id","name","content_type","download_url","size","url"]},{"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":"Upload one or more attachments to a test case","guidance":["Upload one or more attachments to a specific test case within a folder in a project"],"returns":["id","byte_size","content_type","filename"]},{"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 whose per-test-case / per-run / per-day breakdown is requested."}],"intent":"Fetch the User Workload drill-down for a single user","guidance":["User-Workload per-user execution breakdown","Only valid on a report whose type is User Workload Report","every other type returns 400 \"Drill-down is only available for User Workload Reports\""],"shape":"discovered","paginated":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},"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)","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_v1"]},"attachment":{"entity":"attachment","title":"Attachments","aliases":["attachment","file","attachments","blob"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"attached to"},{"entity":"result","via":"attached to"}],"capabilities":["delete_test_case_attachment","get_test_case_attachments_v1","upload_a_i_attachments","upload_generic_attachments","upload_test_case_attachments_v1"]},"configuration":{"entity":"configuration","title":"Configurations","aliases":["config","configuration","environment","browser","os","device"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_run","via":"case status tracked against"}],"capabilities":["create_configuration_v1","get_configurations_v1"]},"custom_field":{"entity":"custom_field","title":"Custom fields & system fields","aliases":["custom field","custom fields","field","system field"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"extends"},{"entity":"result","via":"extends"}],"capabilities":["create_custom_field_dataset_option_v2","create_custom_field_dataset_v2","create_custom_field_definition_v2","delete_custom_field_dataset_option_v2","delete_custom_field_dataset_v2","delete_custom_field_definition_v2","get_custom_field_dataset_v2","get_custom_field_definition_v2","get_custom_field_values_v1","get_project_id_custom_fields_v2_v1","get_test_results_custom_fields_v1","list_custom_field_dataset_options_v2","list_workspace_fields_v2","update_custom_field_dataset_option_v2","update_custom_field_dataset_project_mapping_v2","update_custom_field_definition_v2"]},"dataset":{"entity":"dataset","title":"Dataset","aliases":["datasets","data-driven-data","dds"],"id_convention":"uuid","parents":["project"],"relations":[{"entity":"test_case","via":"drives"},{"entity":"variable","via":"has columns"}],"capabilities":["create_dataset","delete_dataset","get_dataset","get_dataset_linked_test_cases","get_dataset_variables","import_dataset_c_s_v","list_datasets","update_dataset"]},"duplicate":{"entity":"duplicate","title":"Duplicate recommendation (AI dedupe)","aliases":["duplicate","duplicates","dedupe","deduplication","duplicate recommendation","ai duplicates","redundant test cases"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"links the pair it believes are duplicates"}],"capabilities":["discard_duplicate_v1","get_dedupe_status_v1","get_duplicate_source_test_case_v1","get_duplicate_v1","list_duplicates_v1","resolve_duplicate_v1","search_duplicates_v1"]},"exploratory_session":{"entity":"exploratory_session","title":"Exploratory session","aliases":["session","exploratory","et-session"],"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_v1","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"]},"filter":{"entity":"filter","title":"Saved filter view","aliases":["filter","filters","saved view","saved 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"]},"folder":{"entity":"folder","title":"Folder","aliases":["dir","directory"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"folder","via":"nests under"},{"entity":"test_case","via":"organises"}],"capabilities":["copy_folder","create_bulk_test_cases_v1","create_root_folder_v1","create_sub_folder_v1","delete_folder_and_contents","get_folder_remove_summary","get_folder_tree_v1","get_root_folders_v1","list_folder_contents","move_folder_v1","rename_folder_v1","reorder_folders","undo_remove_folder"]},"project":{"entity":"project","title":"Project","aliases":["pr","workspace"],"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_v1","export_dashboard_analytics","get_active_test_runs_info_v1","get_automation_stats_v1","get_closed_test_runs_info_v1","get_closed_test_runs_split_v1","get_entity_filter_details_v1","get_issues_count_info_v1","get_project_by_integer_id_v1","get_project_settings","get_project_users_v2_v1","get_projects_basic_v1","get_projects_minify_v1","get_test_case_count_trend_v1","get_test_case_type_split_v1","list_projects_v1","search_project_entities","update_project_settings"]},"report":{"entity":"report","title":"Report","aliases":["reports","scheduled-report","schedule","analytics-report"],"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","delete_report_schedule","download_report","get_exploratory_session_summary_v1","get_report_attachment_url","get_report_detail","get_report_section_v1","get_report_summary_by_type","get_selected_report_testcases","list_schedules","send_report_email_now_v1","update_report","user_workload_drilldown"]},"result":{"entity":"result","title":"Result","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_v1","create_step_result_v1","create_test_result_for_test_case","delete_test_result_v1","get_test_results_for_test_case","update_latest_test_result_for_test_case"]},"shared_step":{"entity":"shared_step","title":"Shared step","aliases":["shared step","shared steps","reusable step","step template"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"reused by"}],"capabilities":["create_shared_step_v1","delete_shared_step_v1","get_shared_steps_by_i_d_v1","get_shared_steps_v1","update_shared_step_v1"]},"tag":{"entity":"tag","title":"Tag","aliases":["tags","label","labels"],"id_convention":"name","parents":["project"],"relations":[{"entity":"test_case","via":"labels"},{"entity":"test_run","via":"labels"},{"entity":"test_plan","via":"labels"}],"capabilities":["bulk_edit_test_cases_v2","get_test_case_tags","list_test_case_tags_v1","merge_tags_v1","search_group_tags","search_test_case_tags"]},"test_case":{"entity":"test_case","title":"Test case","aliases":["tc","case","test case"],"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":["bulk_archive_test_cases_by_project","bulk_copy_test_cases","bulk_delete_test_cases","bulk_edit_test_cases","bulk_edit_test_cases_in_test_run","bulk_move_test_cases","bulk_retrieve_test_cases","create_test_case_v1","create_test_cases","edit_test_case_partial_v1","edit_test_case_v1","get_archived_test_cases","get_system_field_values_v1","get_test_case_by_integer_id_v1","get_test_case_detail","get_test_cases_v1","list_folder_test_cases_v1","reorder_test_cases_by_folder_v1","search_project_entities_v2","search_test_cases_by_folder_v1"]},"test_plan":{"entity":"test_plan","title":"Test plan (and sub-plan)","aliases":["tp","plan","milestone","sub test plan","subplan","stp"],"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_v1","bulk_retrieve_test_plans_v1","clone_test_plan_v1","count_test_plan_test_runs_v1","create_test_plan_v1","delete_test_plan_v1","get_archived_test_plan_count_v1","get_test_plan_by_integer_id_v1","get_test_plan_execution_trend_v1","get_test_plan_linked_sessions_chart_data_v1","get_test_plan_test_runs_v1","list_archived_test_plans_v1","list_test_plans_v1","update_test_plan_v1"]},"test_run":{"entity":"test_run","title":"Test run","aliases":["tr","run","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":["assign_test_run_owner","bulk_assign_test_cases_to_test_run","clone_test_run","close_test_run_v1","count_run_selection_v1","create_test_run_v1","delete_v1_test_run","edit_test_run","get_closed_test_runs","get_test_cases_for_v1_test_run","get_test_run_by_integer_id_v1","get_test_run_cases_v1","get_test_runs_form_fields_v1","get_test_runs_selection","get_test_runs_v1"]},"user":{"entity":"user","title":"User","aliases":["users","member","assignee","owner"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"project","via":"member of"},{"entity":"test_run","via":"assigned to"},{"entity":"test_case","via":"owns"}],"capabilities":["get_group_users_v2_v1","search_project_users"]},"version":{"entity":"version","title":"Test case version","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":["get_test_case_histories_v1","get_test_case_history_diff_v1","get_test_case_history_v1","restore_history_v1"]}}}}} \ No newline at end of file +{"schema_version":1,"build_id":"8084a81bd6-173caps-b381220","harness_commit":"b3812207","products":{"tm":{"summary":"BrowserStack Test Management API (v1 \u2014 the SSO/OAuth app API, cookie-authenticated).","capabilities":[{"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"}],"body":[{"name":"owner","type":"integer","description":"The ID of the user to assign as the owner of the test run."}],"intent":"Assign a owner to the test run","guidance":["Assign a user as the owner of a specific test run within a project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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, verified live: 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":["Archive multiple test cases within a project","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"paginated":true},{"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,"description":"Plan INTEGER ids."}],"intent":"Archive test plans in bulk (reversible \u2014 prefer this over delete)","guidance":["REVERSIBLY remove plans from view","Async above the bulk threshold","Soft-archive one or more plans","reach for it whenever the user wants plans \"removed\" or \"cleaned up\" without saying they must be destroyed"],"returns":["async","unique_id"]},{"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"}],"query":[{"name":"include_subfolders_tc","type":"boolean"}],"body":[{"name":"assignee_id","type":"integer","example":2,"description":"ID of the assignee to whom the test cases will be assigned"},{"name":"mapping_ids","type":"array","example":[999,991,1024,1019],"description":"List of test case IDs to be linked"},{"name":"select_all","type":"boolean","example":false,"description":"Flag to select all test cases"},{"name":"de_selected_ids","type":"array","description":"List of test case IDs to be de-selected"},{"name":"folder_id","type":"integer","description":"ID of the folder to which the test cases belong"}],"intent":"Bulk assign test cases to a test run","guidance":["assign specific cases within a run to a user (async above 300)","Assign multiple test cases to a specific test run within a project, allowing for bulk operations on test case assignments"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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` \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, verified live: 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":"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,"json_path":"/test_case/ids"},{"name":"de_selected_ids","type":"array","required":true,"json_path":"/test_case/de_selected_ids"},{"name":"select_all","type":"boolean","required":true,"json_path":"/test_case/select_all"},{"name":"folder_id","type":"string","description":"SOURCE folder that scopes the selection, at top level. OPTIONAL when you pass explicit `ids` (verified live: the copy succeeds without it), but send it whenever `select_all` is true \u2014 it is what bounds the set, and `select_all` with `folder_id` and no `q` copies exactly that folder's cases. Integer and string are both accepted; the declared string matches what the UI sends here, which unlike bulk-"},{"name":"destination_folder_id","type":"integer","required":true,"description":"Target folder for the copies, and the one genuinely required destination field \u2014 omitting it is a bare `400`, verified live. NOTE it is TOP-LEVEL for copy, unlike bulk-move where the destination sits inside `test_case`."},{"name":"destination_project_id","type":"string","description":"Target project id. Needed only for a copy to ANOTHER project. For a copy WITHIN one project it is OPTIONAL \u2014 verified live, omitting it copies correctly into `destination_folder_id`, and integer and string are both accepted. The product does both: its Copy/Move modal sends the source project id, its clone / drag-and-drop path omits it. Send the source id to be explicit or omit it; never invent a v"}],"intent":"Bulk copy test cases","guidance":["copy a selection to a folder (async above 30)","Bulk copy test cases from one folder to another within a project","This operation allows you to copy multiple test cases at once, which can be useful for organizing or duplicating test cases across different folders","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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` \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, verified live: 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 delete.","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":"Delete every case in scope, minus `de_selected_ids`.","json_path":"/test_case/select_all"}],"intent":"Bulk Delete Test Cases from Search","guidance":["All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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."}],"intent":"Delete test results in bulk \u2014 PARTIAL SUCCESS is normal, always read `skipped`","guidance":["Authorisation is enforced per id (a caller must be the result's author or a project admin)","treating a 200 as \"all deleted\" will silently misreport","result_ids must be a non-empty array (400 otherwise) of at most 300 ids (422 beyond that)","batch larger sets yourself"],"returns":["id","reason"]},{"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` \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, verified live: 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 edit.","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":"automation_status","type":"string","json_path":"/test_case/automation_status"},{"name":"case_type","type":"integer","json_path":"/test_case/case_type"},{"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"},{"name":"owner","type":"integer","description":"TM user id.","json_path":"/test_case/owner"},{"name":"preconditions","type":"string","json_path":"/test_case/preconditions"},{"name":"priority","type":"integer","json_path":"/test_case/priority"},{"name":"status","type":"integer","json_path":"/test_case/status"},{"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"}],"intent":"Bulk Update Test Cases","guidance":["bare values, REPLACE-only (async above 100)","Update multiple test cases within a project","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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"}],"query":[{"name":"include_subfolders_tc","type":"boolean"},{"name":"status_id","type":"integer"}],"body":[{"name":"attachments","type":"array","example":[]},{"name":"de_selected_ids","type":"array","example":[]},{"name":"description","type":"string","example":"

something

","description":"HTML formatted comment or note"},{"name":"folder_id","type":"integer","example":21},{"name":"issues","type":"array","example":[]},{"name":"mapping_ids","type":"array","example":[999,991,1024,1019]},{"name":"select_all","type":"boolean","example":false},{"name":"status_id","type":"integer","example":21},{"name":"test_run_ids","type":"array","example":[1001,1002,1003],"description":"Array of BTCER IDs to apply the bulk edit to (passed when automation = true)"},{"name":"test_case_counts","type":"array","example":[1,3,2],"description":"Array of test case counts corresponding to each BTCER ID in test_run_ids (passed when automation = true)"}],"intent":"Bulk edit test cases result in test run","guidance":["Update multiple test cases in a specific test run within a project, allowing for bulk operations such as changing status, adding attachments, and more"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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, verified live: 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},{"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},{"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`.\n\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},{"name":"value","type":"string"}],"json_path":"/test_case/priority"},{"name":"status","type":"string","fields":[{"name":"operation","type":"string","required":true},{"name":"value","type":"string"}],"json_path":"/test_case/status"},{"name":"case_type","type":"string","fields":[{"name":"operation","type":"string","required":true},{"name":"value","type":"string"}],"json_path":"/test_case/case_type"},{"name":"automation_state","type":"string","fields":[{"name":"operation","type":"string","required":true},{"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},{"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},{"name":"value","type":"string"}],"json_path":"/test_case/owner"},{"name":"preconditions","type":"string","description":"Text value.","fields":[{"name":"operation","type":"string","required":true},{"name":"value","type":"string"}],"json_path":"/test_case/preconditions"},{"name":"review_status","type":"string","fields":[{"name":"operation","type":"string","required":true},{"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},{"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":["per-field { value, operation }","Correct for one case too (pass a single id)","Update many test cases in ONE call","Every field is an object of the form {\"value\": \u2026, \"operation\": \u2026}","so this is the only write path that can ADD or REMOVE from a collection without replacing it","one call for N cases instead of N calls"],"returns":["async","unique_id"],"paginated":true},{"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` \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, verified live: 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 move.","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":"Move every case in scope, minus `de_selected_ids`.","json_path":"/test_case/select_all"},{"name":"destination_folder_id","type":"integer","description":"Target folder id. Falls back to `folder_id` when omitted.","json_path":"/test_case/destination_folder_id"},{"name":"folder_id","type":"integer","description":"Legacy alias for `destination_folder_id`, used only when that is absent.","json_path":"/test_case/folder_id"},{"name":"destination_project_id","type":"integer","description":"Set only for a cross-project move. Shared cases cannot be moved across projects (422).","json_path":"/test_case/destination_project_id"}],"intent":"Bulk Move Test Cases","guidance":["Move multiple test cases from one folder to another within a project","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"paginated":true},{"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":"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, verified live: 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 Retrieve Test Cases","guidance":["Retrieve multiple test cases within a project based on specified criteria","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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,"description":"Plan INTEGER ids."}],"intent":"Un-archive test plans in bulk (undo bulk-archive)","guidance":["Restore previously archived plans"],"returns":["async","unique_id"]},{"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":"Clone an exploratory session (async when it has many logs).","guidance":["defects are the issues linked to the session","the parent project takes the INTEGER project id (v1)","List defaults to active sessions"]},{"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","description":"Name for the clone. Defaults to a server-generated copy name.","json_path":"/test_plan/name"},{"name":"copy_all_sub_plans","type":"boolean","json_path":"/test_plan/copy_all_sub_plans"},{"name":"sub_plan_ids","type":"array","description":"Clone only these sub-plans. Ignored when copy_all_sub_plans is true.","json_path":"/test_plan/sub_plan_ids"}],"intent":"Clone a test plan, optionally with its sub-plans","guidance":["copy_all_sub_plans or sub_plan_ids to bring its sub-plans along","Copy an existing plan","copy_all_sub_plans: true clones every sub-plan","alternatively pass sub_plan_ids to clone a specific subset","Omit both to clone just the plan itself"]},{"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"}],"body":[{"name":"copy_tc_assignee","type":"boolean","description":"Copy test case assignees from the original run"},{"name":"copy_issues","type":"boolean","description":"Copy issues linked to the original run"},{"name":"copy_tags","type":"boolean","description":"Copy tags from the original run"},{"name":"copy_all_cases","type":"boolean","description":"Include all test cases in the cloned run"},{"name":"status","type":"array","example":["passed","failed"],"description":"Status strings to filter test cases to copy"},{"name":"status_id","type":"array","example":[1,2],"description":"Status IDs to filter test cases to copy"},{"name":"name","type":"string","example":"Cloned Test Run - 2025","description":"Name for the new cloned test run"}],"intent":"Clone a test run","guidance":["Create a new test run by cloning an existing one, with options to copy test case assignees, issues, tags, and filter cases by status"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"Close an exploratory session","guidance":["Transitions an active exploratory session to the closed state"]},{"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"}],"intent":"Close a test run","guidance":["Close a specific test run within a project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"ID of folder to copy"},{"name":"destination_folder_id","type":"integer","required":true,"example":3,"description":"ID of destination parent folder"},{"name":"destination_project_id","type":"integer","required":true,"example":10000,"description":"Destination project ID (can be same or different)"},{"name":"async","type":"boolean","example":true,"description":"Whether to process asynchronously via background job"},{"name":"copy_test_cases","type":"boolean","example":true,"description":"Whether to copy test cases within folder"}],"intent":"Copy a folder to another location","guidance":["Copies a folder (and optionally its contents) to a destination folder within the same or different project","Supports both synchronous and asynchronous operations via the async flag","Async Processing: When async: true, the operation is queued as a Sidekiq background job and returns immediately with a unique tracking ID"],"returns":["async","folder_id","unique_id"]},{"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","example":false,"description":"Select every case in the project.","json_path":"/selection/select_all"},{"name":"unique_test_case_count","type":"integer","example":2,"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.","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`."},{"name":"configurations","type":"array","required":true,"example":[],"description":"Configuration ids. Required \u2014 pass an empty array if there are none."},{"name":"trtc_configuration_mappings","type":"array","description":"Per-configuration case mappings. An ARRAY of objects \u2014 the server rejects an object here.","fields":[{"name":"configuration_id","type":"integer"},{"name":"select_all","type":"boolean"},{"name":"linked_test_cases","type":"array"},{"name":"unlinked_test_cases","type":"array"}]}],"intent":"Count the test cases a selection resolves to (incl. configurations/datasets).","guidance":["The discovered ids must be CARRIED INTO the create body","discovery alone puts nothing in the run","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","It is validated before the selection","so a bare 400 'invalid data' means a bad test_run field"],"shape":"discovered"},{"method":"GET","path":"/api/v1/projects/{project_id}/test-plans/{test_plan_id}/test-runs/count","mode":"read","entity":"test_plan","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"test_plan_id","type":"integer","required":true}],"intent":"Count the test runs linked to a test plan","guidance":["how many runs a plan groups","cheaper and safer than paging the run list","Returns just the count of runs linked to the plan","Prefer this over paging the run list when the user only asked \"how many\"","list reads truncate around 30 KB and will make you under-count"],"shape":"discovered"},{"method":"POST","path":"/api/v1/projects/{project_id}/folder/{folder_id}/bulk-test-cases","mode":"write","entity":"folder","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"folder_id","type":"integer","required":true}],"body":[{"name":"test_cases","type":"array","required":true,"fields":[{"name":"name","type":"string","required":true},{"name":"test_case_folder_id","type":"string","required":true},{"name":"attachments","type":"array"},{"name":"automation_state","type":"integer"},{"name":"automation_status","type":"string"},{"name":"case_type","type":"integer"},{"name":"custom_fields","type":"object"},{"name":"description","type":"string"},{"name":"estimate","type":"string"},{"name":"estimated_duration_seconds","type":"integer"},{"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"},{"name":"reviewers","type":"array"},{"name":"send_for_review","type":"boolean"},{"name":"shared_precondition_id","type":"integer"},{"name":"status","type":"integer"},{"name":"tags","type":"array"},{"name":"template","type":"string"},{"name":"template_id","type":"integer"},{"name":"template_step_type","type":"string"},{"name":"test_case_dataset","type":"string"},{"name":"test_case_steps","type":"array"}]},{"name":"unique_id","type":"string","description":"Client-supplied idempotency key. When present and the group has the async bulk-create feature flag enabled, the request is processed asynchronously and acknowledged with 202; completion is delivered over the common WebSocket channel keyed by this id."}],"intent":"Create test cases in bulk for a folder (\u226410) \u2014 UNRELIABLE STATUS, see description.","guidance":["Creates several test cases in one request","more returns 400 \"More than permitted test cases sent\"","A 500 here does NOT mean nothing happened","The action is wrapped in a catch-all rescue that renders 500 {\"success\": false, \"message\": \"Failed to create test cases.\"} for any exception, including ones raised AFTER the cases were already inserted","Same status, same message, opposite outcomes","So on a 500: never retry blind"],"returns":["request_trace_id"]},{"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 new configuration for a project","guidance":["there is no flat, account-level list","Each configuration has an INTEGER id","the list returns a configurations[] array plus page info"],"returns":["id","name","created_at","group_id","is_suggested","record_status"]},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"body":[{"name":"option_value","type":"string","required":true,"description":"The option label."},{"name":"is_default","type":"boolean","required":true,"description":"REQUIRED \u2014 a missing value is a 400. Must be 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 parent option this hangs under."}],"intent":"Add one option to a dataset \u2014 how you add a dropdown value.","guidance":["option_value + is_default both required","Adds a single option","Both option_value and is_default are required","a missing is_default is 400 \"Invalid Params\"","For dropdown, at most one option may be the default"]},{"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}],"body":[{"name":"linked_projects","type":"array","description":"Project INTEGER ids this dataset applies to. This is what makes the field visible in a project \u2014 a dataset with no linked projects leaves the field invisible everywhere. NOTE the spelling differs from the project-mappings endpoint, which uses `link_projects` / `unlink_projects`."},{"name":"link_to_future_projects","type":"boolean","description":"Auto-link projects created later."},{"name":"linked_to_all_projects","type":"boolean","description":"Apply to every project in the workspace."},{"name":"options","type":"array","description":"The allowed values, for dropdown-style fields.","fields":[{"name":"option_value","type":"string","required":true},{"name":"is_default","type":"boolean","required":true},{"name":"parent_option_id","type":"integer"}]}],"intent":"Add a dataset (an option set + its project links) to an existing field.","guidance":["A dataset is how a field carries options and project scope","A field can hold more than one, letting different projects see different option sets for the same field","the key is linked_projects, and options is accepted at this level (it is a dataset body, not a field body)","for other types a dataset with linked_projects and no options is what scopes the field to projects"]},{"method":"POST","path":"/api/v1/admin-v2/settings/custom-fields","mode":"write","entity":"custom_field","body":[{"name":"field_name","type":"string","required":true},{"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 (e.g. `field_dropdown`) are a different API's and are rejected. Silently immutable after creation: an update that changes it returns 200 and changes nothing."},{"name":"entity_type","type":"string","values":["TestCase","TestResult","TestPlan","ExploratorySession"],"description":"Which entity the field hangs off. One field belongs to exactly one."},{"name":"is_required","type":"boolean","required":true,"description":"REQUIRED, and must be a literal boolean \u2014 omitting it is a 400."},{"name":"datasets","type":"array","required":true,"description":"REQUIRED for every field type, dropdown or not \u2014 omitting it is a 400. Carries the options and the project scope. Send one entry with `linked_projects` set.","fields":[{"name":"linked_projects","type":"array"},{"name":"link_to_future_projects","type":"boolean"},{"name":"linked_to_all_projects","type":"boolean"},{"name":"options","type":"array"}]},{"name":"place_holder_text","type":"string"},{"name":"default_value","type":"string","description":"Only honoured when `field_type` is `boolean`."},{"name":"levels","type":"array","description":"REQUIRED when field_type is `nested_dropdown`, minimum 2 entries, each {name, level_type}."}],"intent":"Create a custom field DEFINITION (workspace-admin action).","guidance":["options AND project scope both go in datasets[]","Creates the field definition","a new attribute available on the chosen entity type","configurable per workspace","so don't predict access","A caller without it gets 403"]},{"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,"description":"Name of the dataset.","json_path":"/dataset/name"},{"name":"variables","type":"array","required":true,"description":"List of dataset variables/columns (max 40).","fields":[{"name":"name","type":"string","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/variables"},{"name":"rows","type":"array","required":true,"description":"List of dataset rows (max 100).","fields":[{"name":"row_number","type":"integer","required":true},{"name":"row_data","type":"array","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/rows"}],"intent":"Create a new dataset.","guidance":["create a dataset with variables + rows","Create a new dataset with variables and rows for a project"],"returns":["name","created_at","rows_count","uuid"]},{"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.","json_path":"/exploratory_session/title"},{"name":"description","type":"string","example":"Explore edge cases in the checkout flow.","description":"Optional description.","json_path":"/exploratory_session/description"},{"name":"charter","type":"string","example":"Focus on payment error handling","description":"Testing charter describing the scope and goals.","json_path":"/exploratory_session/charter"},{"name":"timebox_duration","type":"integer","example":60,"description":"Planned duration in minutes.","json_path":"/exploratory_session/timebox_duration"},{"name":"owner","type":"integer","example":42,"description":"TCM user ID of the session owner / assignee.","json_path":"/exploratory_session/owner"},{"name":"assignee","type":"integer","example":42,"description":"Alias for `owner`. TCM user ID of the assignee.","json_path":"/exploratory_session/assignee"},{"name":"folder_id","type":"integer","example":456,"description":"ID of the folder to place this session in.","json_path":"/exploratory_session/folder_id"},{"name":"tags","type":"array","example":["regression","mobile"],"description":"Tags for the session.","json_path":"/exploratory_session/tags"},{"name":"configurations","type":"array","example":[1,3],"description":"Configuration IDs to associate with this session.","json_path":"/exploratory_session/configurations"},{"name":"attachments","type":"array","example":[101,102],"description":"Blob / media attachment IDs.","json_path":"/exploratory_session/attachments"},{"name":"issues","type":"array","description":"Issues to link to this session.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/exploratory_session/issues"}],"intent":"Create an exploratory session","guidance":["body wrapped in exploratory_session (title required)","Creates a new exploratory session within the specified project"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"]},{"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 button \u2014 spinner appeared but order was not created.

","description":"Rich-text HTML content of the log entry.","json_path":"/session_log/content"},{"name":"status","type":"string","values":["pass","fail","bug","retest","block","note"],"example":"fail","description":"Test result status for this log step.","json_path":"/session_log/status"},{"name":"elapsed_time","type":"integer","example":120,"description":"Time elapsed for this step in seconds.","json_path":"/session_log/elapsed_time"},{"name":"defects","type":"array","description":"Defects to link to this log entry.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/session_log/defects"}],"intent":"Create a session log entry","guidance":["add a log entry (rich-text HTML, sanitised on write)","Adds a new log entry to the specified exploratory session","Rich-text HTML content is sanitised before storage"],"returns":["id","status","content","created_at","elapsed_time","session_id","updated_at"]},{"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,"description":"Name of the filter"},{"name":"entity","type":"string","required":true,"values":["testcase","testplan","testrun","exploratory_session","test_plan_test_case"],"description":"Entity type the filter applies to. The server's validator accepts five values (the\nlast two were missing here); an unlisted value returns 400 \"Invalid JSON data\"."},{"name":"is_project_level","type":"boolean","description":"Whether this filter is available at project level"},{"name":"filters","type":"object","required":true,"description":"Filter criteria object containing the actual filter conditions"}],"intent":"Create a new filter","guidance":["save a filter view ({ name, entity, filters, is_project_level })","Create a new saved filter for test cases, test plans, or test runs in a project"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"]},{"method":"POST","path":"/api/v1/projects","mode":"write","entity":"project","body":[{"name":"name","type":"string","required":true,"json_path":"/project/name"},{"name":"description","type":"string","json_path":"/project/description"}],"intent":"Create a new project.","guidance":["Pinned to human approval","Create a new project with name and description"],"returns":["name","description"]},{"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},{"name":"report_type","type":"string","required":true,"values":["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","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_statu"},{"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 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`"},{"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;\n`custom_report` is a user-configured one. For a Requirement Traceability\nreport, `system_generated` makes the server auto-populate\n`report_filters.requirements` with every requirement in the project."},{"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"},{"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":"Integer run ids \u2014 pairs with report_timeframe=specific_test_runs.","json_path":"/report_filters/test_runs"},{"name":"test_plans","type":"array","description":"Integer plan ids \u2014 pairs with report_timeframe=specific_test_plans.","json_path":"/report_filters/test_plans"},{"name":"test_cases","type":"string","description":"Case selection \u2014 pairs with report_timeframe=specific_test_cases. FOLDER-KEYED\n(see SelectionData), never a flat id list: the server resolves the selection\nthrough its folder map, so any other shape yields zero cases and saves an\nUNSCOPED, project-wide report at 200.\n\nSend all three keys. Folder ids are STRING keys; test-case ids are INTEGERS\n(the numeric `id`, not the TC-NNN identifier) \u2014 take bo","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":"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.","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 } }.","json_path":"/report_filters/test_plan_selection"},{"name":"builds","type":"array","json_path":"/report_filters/builds"},{"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\n`Test Run Summary`, `Test Run Detailed Report` and `Test Case Activity` \u2014 on\nthe other six report types the server never reads it and drops it silently.\nPersisted (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`."},{"name":"users","type":"array","json_path":"/mail_to/users"},{"name":"external_mails","type":"array","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\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"},{"name":"time","type":"string","example":"08:00","description":"24-hour HH:MM, UTC.","json_path":"/frequency_details/time"},{"name":"day","type":"string","example":"monday","description":"Required for weekly (lowercase day name) and monthly (integer 1-28).\nOmit for daily.","json_path":"/frequency_details/day"}],"intent":"Create a new scheduled report for a project","guidance":["Create a report configuration under the given project"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"]},{"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","description":"Name of the root folder"},{"name":"notes","type":"string","example":"This folder contains all regression test cases","description":"Optional notes or description for the folder"}],"intent":"Create a root-level folder for test cases in a project","returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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},{"name":"folder_id","type":"integer","description":"ID of the shared-field folder to place the shared step in. Null or omitted means the root (Unassigned)."},{"name":"shared_step_details","type":"array","required":true,"fields":[{"name":"order","type":"integer","required":true},{"name":"step","type":"string"},{"name":"result","type":"string"},{"name":"test_data","type":"string"}]}],"intent":"Create a new shared step in a project","guidance":["read the shared step first and send back the full array with your edit applied, or you will drop steps","Include each detail's id to update it in place rather than recreating it","A shared step is embedded by MANY test cases","so editing or deleting it changes every one of them at once","say how it is used before changing it"],"returns":["id","title","step_count","test_case_count"]},{"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},{"name":"test_run_id","type":"string","required":true},{"name":"test_case_id","type":"integer","required":true},{"name":"step_id","type":"integer","required":true}],"body":[{"name":"status_id","type":"integer","json_path":"/test_run_step_result/status_id"},{"name":"description","type":"string","json_path":"/test_run_step_result/description"}],"intent":"Record a per-step result for a case in a run (v1).","guidance":["record a per-step outcome for a step-templated case ({ status_id, description })"]},{"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,"description":"Name of the new folder.","json_path":"/folder/name"},{"name":"notes","type":"string","description":"Description of the new folder.","json_path":"/folder/notes"}],"intent":"create subfolder inside a specific folder","guidance":["Create a new subfolder within a specific folder in a project"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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 the FIRST test case in a project that has no folders (needs create_at_root).","guidance":["Narrow-purpose route: the first case in a project that has zero folders","The route and the flag always travel together","The product's UI derives both from one boolean","no folders exist AND the user picked no folder","A correct refusal, not something to retry: list the folders and pick one","The text sits under errors[], not message"],"returns":["result","step"]},{"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,"fields":[{"name":"name","type":"string","required":true},{"name":"test_case_folder_id","type":"string","required":true},{"name":"attachments","type":"array"},{"name":"automation_state","type":"integer"},{"name":"automation_status","type":"string"},{"name":"case_type","type":"integer"},{"name":"custom_fields","type":"object"},{"name":"description","type":"string"},{"name":"estimate","type":"string"},{"name":"estimated_duration_seconds","type":"integer"},{"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"},{"name":"reviewers","type":"array"},{"name":"send_for_review","type":"boolean"},{"name":"shared_precondition_id","type":"integer"},{"name":"status","type":"integer"},{"name":"tags","type":"array"},{"name":"template","type":"string"},{"name":"template_id","type":"integer"},{"name":"template_step_type","type":"string"},{"name":"test_case_dataset","type":"string"},{"name":"test_case_steps","type":"array"}]},{"name":"create_at_root","type":"boolean"}],"intent":"Create test cases for a folder in a project.","guidance":["body wrapped in test_case, name required","Create one or more test cases within a specific folder in a project","If create_at_root is true, the test case will be created at the root of the project"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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","json_path":"/test_plan/name"},{"name":"description","type":"string","json_path":"/test_plan/description"},{"name":"start_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/start_date"},{"name":"end_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/end_date"},{"name":"plan_status","type":"string","description":"Plan status. The list filter accepts new / started / completed.","json_path":"/test_plan/plan_status"},{"name":"owner","type":"integer","description":"Owner user id \u2014 resolve via the project users read first.","json_path":"/test_plan/owner"},{"name":"parent_plan_id","type":"integer","description":"Parent plan's INTEGER id. Set it to create (or re-parent into) a SUB-plan \u2014 the\nresult carries an STP-NNN identifier. Null/omitted means a top-level plan.","json_path":"/test_plan/parent_plan_id"},{"name":"tags","type":"array","json_path":"/test_plan/tags"},{"name":"reviewers","type":"array","description":"Reviewer user ids.","json_path":"/test_plan/reviewers"},{"name":"attachments","type":"array","description":"Attachment ids already uploaded via the attachments surface.","json_path":"/test_plan/attachments"},{"name":"custom_fields","type":"object","json_path":"/test_plan/custom_fields"},{"name":"issues","type":"array","description":"Linked tracker issues. Structured \u2014 NOT a flat array of keys.","fields":[{"name":"issue_id","type":"string"},{"name":"issue_type","type":"string"},{"name":"metadata","type":"object"}],"json_path":"/test_plan/issues"},{"name":"test_runs","type":"string","description":"The plan's linked test runs. Accepts EITHER an array of test-run integer ids, OR a\nbulk-selection object for \"every run matching this filter\". On update this REPLACES\nthe existing link set rather than appending.","json_path":"/test_plan/test_runs"}],"intent":"Create a test plan \u2014 or a SUB-plan, by setting parent_plan_id","guidance":["body wrapped in test_plan","Create a test plan in the project","Everything goes under the test_plan key","test_runs accepts two shapes","Both are honoured server-side","an unknown option is rejected"]},{"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"},{"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":["The case is in the PATH here, not the body","Bodies are wrapped in test_result","set status by NAME (status) or id (status_id)","use a real configured status (see statuses-and-states)","so a stray string silently CREATES a link","Deleting removes an execution record and the case's latest status is recomputed from what remains, which can silently change the run's reported state"],"returns":["id","status","backtrace","configuration_id","created_at","created_by_imported","custom_fields","description","error_description","expanded","failure_type","finished_at"]},{"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],"description":"Explicit case ids to pin into the run. Top level, NOT inside `test_run`. Use this or `selection`."},{"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":["Create a new test run for a specific project, which can be used to execute test cases"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"dataset_id","type":"integer","required":true},{"name":"option_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"Delete one option (POST to /delete) \u2014 DESTRUCTIVE, drops values using it.","guidance":["destroys the values that used it","Removing an option deletes the stored values that used it, across every project linked to the dataset","that preserves the values"]},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"Delete a dataset (POST to /delete) \u2014 DESTRUCTIVE, drops its options and values.","guidance":["drops its options and the values the linked projects stored","Removes the option set and unlinks its projects, destroying the values those projects had stored for this field","Name the affected projects before calling"]},{"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}],"intent":"Delete a field definition (POST to /delete) \u2014 DESTRUCTIVE, cascades to stored values.","guidance":["destroys every stored value in every linked project","Name the projects first","Removes the definition and every value stored for it, in every project it is linked to","There is no bin and no undo","Before calling: read the field, say how many projects it is linked to, and name them","If the user only wants it hidden rather than destroyed, that is a different ask"]},{"method":"DELETE","path":"/api/v1/projects/{project_id}/datasets/{uuid}","mode":"destructive","entity":"dataset","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"uuid","type":"string","required":true}],"intent":"Delete a dataset.","guidance":["say it isn't available rather than calling it, and don't substitute by parsing the file yourself","A 422 here means NOT-ENTITLED, not a bad payload","say so and stop, don't retry with a different body","BINDING shape is DIFFERENT from the dataset shape, and this is the usual failure","the dataset's uuid repeated on every column it owns","The binding object has NO top-level uuid or name (both stripped by strong params)"]},{"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":"Delete an exploratory session","guidance":["defects are the issues linked to the session","the parent project takes the INTEGER project id (v1)","List defaults to active sessions"]},{"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":"Delete a session log entry","guidance":["Permanently deletes a log entry from the specified exploratory session"]},{"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 filter","guidance":["entity = testcase | testplan | testrun | exploratory_session","filter_id is an integer","reuse a saved view's filters as the q for a later search or bulk action"]},{"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":"Delete a folder and its contents","guidance":["Deletes a folder by its ID and optionally returns the parent folder's path hierarchy"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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"}],"intent":"Delete a report (this removes the report itself, not just its schedule)","guidance":["returns the remaining reports","merely stopping a report from recurring","The response is the list of remaining reports","There is no bin and no undo","so confirm the report's name with the user first"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"]},{"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","guidance":["affects every test case embedding it","Deletes a shared step from a project"]},{"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":"Delete an attachment from a test case in a folder in a project","guidance":["read the file from that url"],"returns":["id","byte_size","content_type","filename"]},{"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":"Delete a test plan (or sub-plan) \u2014 no bin, no undo","guidance":["The server performs no plan-type check","so this deletes a sub-plan by its own integer id exactly the same way","Deleting a parent plan is destructive to its children"]},{"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 test result","guidance":["Prefer PATCHing the latest result to correct a mistake","Deleting a result removes an execution record","the case's latest status is recomputed from what remains"]},{"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"}],"intent":"delete test run","guidance":["The discovered ids must be CARRIED INTO the create body","discovery alone puts nothing in the run","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","It is validated before the selection","so a bare 400 'invalid data' means a bad test_run field"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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},{"name":"duplicate_id","type":"integer","required":true}],"body":[{"name":"discard_reason","type":"string","description":"Short reason code / label for why this isn't a duplicate."},{"name":"user_feedback","type":"string","description":"Free-text feedback from the user."}],"intent":"Discard a duplicate suggestion \u2014 dismisses it WITHOUT touching test cases","guidance":["The safe default when the user says it's not a duplicate or is unsure","Mark a recommendation as discarded","This is the safe way to dismiss a suggestion: it changes only the recommendation's status to discarded and leaves both test cases exactly as they are","discard_reason and user_feedback are optional but feed the dedupe model","pass along whatever reason the user gave"]},{"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":"Desired export format. Note this is a STRING here, while the same-named field\non `send_email_now` is an ARRAY."},{"name":"required","type":"object","description":"Optional column selection, honoured only for `Test Run Detailed Report`\n(ignored for every other type). Shape:\n`{ \"\": { \"fields\": [\"id\",\"title\",...] } }`. Omit to get the report's\ndefault columns."}],"intent":"Initiate a download of a report in a specified file format","guidance":["Request generation and download channel for the specified report"],"returns":["channel"]},{"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"},{"name":"custom_fields","type":"object","example":{"123":"option_value","456":["multi","select"]},"json_path":"/test_case/custom_fields"},{"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"},{"name":"preconditions","type":"string","json_path":"/test_case/preconditions"},{"name":"priority","type":"integer","example":483,"json_path":"/test_case/priority"},{"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\".","json_path":"/test_case/reviewers"},{"name":"status","type":"integer","example":496,"json_path":"/test_case/status"},{"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":"string","description":"Dataset binding for a data-driven case. On this path it is applied only when `is_dataset_modified` is true.","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"}],"intent":"Partially edit a test case (v2)","guidance":["PREFERRED partial update","send ONLY changed fields","Partially update the details of a test case within a specific folder in a project","it is the authoritative list","send test_case_steps instead - estimate is accepted and then dropped","estimated_duration_seconds is the one that persists"],"returns":["result","step"]},{"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"},{"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"},{"name":"custom_fields","type":"object","json_path":"/test_case/custom_fields"},{"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"},{"name":"name","type":"string","json_path":"/test_case/name"},{"name":"owner","type":"integer","json_path":"/test_case/owner"},{"name":"preconditions","type":"string","json_path":"/test_case/preconditions"},{"name":"priority","type":"integer","json_path":"/test_case/priority"},{"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"},{"name":"tags","type":"array","json_path":"/test_case/tags"},{"name":"template","type":"string","json_path":"/test_case/template"},{"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":"string","description":"Dataset binding for a data-driven case. On create the mappings are always regenerated from this, so `is_dataset_modified` is ignored here.","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","json_path":"/test_case/test_case_steps"}],"intent":"Edit a test case","guidance":["Update the details of a test case within a specific folder in a project","Omitted fields are left untouched EXCEPT template, template_id and review_status, which are reset to defaults"],"returns":["result","step"]},{"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"}],"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],"description":"Explicit case ids to pin into the run. Top level, NOT inside `test_run`. Use this or `selection`."},{"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":"Edit Test Run","guidance":["Update the details of a specific test run within a project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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","pdf","excel"],"example":"csv","description":"Export file format"},{"name":"start_date","type":"string","example":"2025-01-01","json_path":"/filters/start_date"},{"name":"end_date","type":"string","example":"2025-12-31","json_path":"/filters/end_date"},{"name":"status","type":"array","example":["passed","failed"],"json_path":"/filters/status"}],"intent":"Export dashboard analytics data","guidance":["Initiates an asynchronous export of dashboard analytics data in the specified format","The export is processed via background job and returns an export ID for tracking","Async Processing: Data export runs via Sidekiq ExportDashboardWorker::FileExportWorker","Supported Formats: CSV, PDF, Excel (based on type parameter)"],"returns":["export_id"]},{"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":"Get active test run result statistics","guidance":["Retrieve statistics of active test runs for a specific project"],"shape":"discovered"},{"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":"Get archived test cases.","guidance":["Retrieve a list of archived test cases in a specific project"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"Count archived test plans","guidance":["Returns {success, count}","the number of archived plans in the project"],"shape":"discovered"},{"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 automation stats analytics","guidance":["Retrieve automation statistics for a specific project"],"returns":["automated_coverage","automated_test_cases","empty_data","manual_test_cases","total_test_cases"]},{"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":"Get all the closed test runs for a project","guidance":["Retrieve a list of all closed test runs for a specific project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"Get closed test run analytics","guidance":["Retrieve statistics of closed test runs for a specific project"],"returns":["month"]},{"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":"Get closed test runs split analytics","guidance":["Retrieve split statistics of closed test runs for a specific project"],"returns":["name"]},{"method":"GET","path":"/api/v1/projects/{project_id}/configurations","mode":"read","entity":"configuration","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Get configurations for a project","guidance":["Retrieve a list of configurations for a specific project"],"returns":["id","name","created_at","group_id","is_suggested","record_status"]},{"method":"GET","path":"/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets/{dataset_id}","mode":"read","entity":"custom_field","path_params":[{"name":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"Read one dataset \u2014 its project links and option set.","guidance":["If nothing matches, the field doesn't exist in that workspace","say so rather than guessing a field id","Multi-dropdowns need OPTION IDS, not labels","That legacy family writes a table local to the Rails app that is DEPRECATED and that nothing reads","It is blocked at the gate","testhub owns definitions"],"shape":"discovered"},{"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}],"intent":"Read one custom field definition, with its datasets and project links.","guidance":["do this BEFORE any update, which is a full replace","Options are NOT inline","Datasets come back WITHOUT their options inline","Read this before any update","so you need the current values to avoid clearing them"],"shape":"discovered"},{"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},{"name":"entity_type","type":"string","required":true,"values":["test-case","test-result","test-plan"]},{"name":"field_id","type":"string","required":true}],"query":[{"name":"q","type":"string"},{"name":"p","type":"integer"}],"intent":"Get the allowed values/options of one custom field.","guidance":["If nothing matches, the field doesn't exist in that workspace","say so rather than guessing a field id","Multi-dropdowns need OPTION IDS, not labels","That legacy family writes a table local to the Rails app that is DEPRECATED and that nothing reads","It is blocked at the gate","testhub owns definitions"],"shape":"discovered","paginated":true},{"method":"GET","path":"/api/v1/projects/{project_id}/datasets/{uuid}","mode":"read","entity":"dataset","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"uuid","type":"string","required":true}],"intent":"Get a specific dataset.","guidance":["read one dataset by UUID","Retrieve a specific dataset by its UUID including all variables and rows"],"returns":["name","created_at","rows_count","uuid"]},{"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},{"name":"uuid","type":"string","required":true}],"query":[{"name":"p","type":"integer"}],"intent":"Get test cases linked to a dataset","guidance":["list the test cases bound to this dataset (paged p)","Returns the test cases linked to a dataset, identified by its UUID"],"shape":"discovered","paginated":true},{"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"},{"name":"p","type":"integer"}],"intent":"Get dataset variables/columns.","guidance":["BINDING shape is DIFFERENT from the dataset shape, and this is the usual failure","the dataset's uuid repeated on every column it owns","The binding object has NO top-level uuid or name (both stripped by strong params)","so do NOT copy the dataset's read shape into it","Never report a dataset as linked from the 200 alone","A dataset is addressed by a UUID (the dataset path param), NOT an integer"],"returns":["name","uuid"],"paginated":true},{"method":"GET","path":"/api/v1/projects/{project_id}/ai-duplicates/status","mode":"read","entity":"duplicate","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Dedupe job status \u2014 use this to tell \"feature off\" from \"no duplicates\"","guidance":["ALWAYS call this when the list is empty","Status of the project's most recent dedupe run"],"returns":["status"]},{"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},{"name":"duplicate_id","type":"integer","required":true}],"intent":"Read the pre-merge snapshot of a MERGED duplicate's source test case","guidance":["after a merge, read the pre-merge snapshot of the case that was folded away","After a merge, the source case no longer exists independently","this returns the snapshot captured at merge time","the only way to show the user what was folded away","Only works for status merged (404 otherwise), and the snapshot may legitimately be absent, which also surfaces as a 404 with error: \"Source test case snapshot not available\"","Treat that as \"not retained\", not as an error to retry"],"shape":"discovered"},{"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},{"name":"duplicate_id","type":"integer","required":true}],"intent":"Read one suggested duplicate, with its test cases resolved","guidance":["do this BEFORE merging so the user can see what would be destroyed","Only status=suggested is readable","Full detail for one recommendation, including the actual test cases it links","so you can show the user what would be merged","Only status suggested is readable here","already-resolved duplicates return 404"],"shape":"discovered"},{"method":"GET","path":"/api/v1/projects/{project_id}/{entity}/filter-details","mode":"read","entity":"project","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"entity","type":"string","required":true,"values":["test-cases","trtc","test-runs"],"example":"test-cases"}],"query":[{"name":"q[query]","type":"string","example":"login functionality"},{"name":"q[folder_ids]","type":"string","example":"3306878,3669741,5422801,5422802"},{"name":"q[status]","type":"string","example":"9319,9321"},{"name":"q[priority]","type":"string","example":"550376,9261"},{"name":"q[automation_state]","type":"string","example":"18172471,18172473"},{"name":"q[case_type]","type":"string","example":"9379,9375"},{"name":"q[owner]","type":"string","example":"user123,user456"},{"name":"q[assignee]","type":"string","example":"user789,user101"},{"name":"q[tags]","type":"string","example":"regression,smoke"},{"name":"q[created_at]","type":"string","example":"2024-01-01..2024-12-31"},{"name":"q[updated_at]","type":"string","example":"2024-01-01..2024-12-31"}],"intent":"Get filter details for entity","guidance":["Retrieve filter details for a specific entity type (test-cases, trtc, or test-runs) within a project"],"returns":["id","colour","entity_type","field_name","field_value","internal_name"]},{"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":"Get an exploratory session","guidance":["read one session by INTEGER id","Returns a single exploratory session by its ID"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"]},{"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"]},{"name":"start_date","type":"string"},{"name":"end_date","type":"string"},{"name":"session_ids","type":"string"}],"intent":"Exploratory Session Summary \u2014 live view, no saved report needed","guidance":["exploratory-session summary WITHOUT creating a report","Computes an exploratory-session summary on demand","there is no Schedule row behind it","Use it to answer \"summarise our exploratory sessions\" without creating a report first","Returns session counts, bugs found, top testers (resolved to user objects) and the session list"],"shape":"discovered"},{"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":"Get filter details","guidance":["Retrieve details of a specific saved filter by its ID","It does NOT validate or strip invalid custom-field references","a filter saved with a non-existent custom-field id is returned unchanged","A missing or deleted filter comes back as 400 {\"success\": false, \"message\": \"Filter not found\"}, not 404"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"]},{"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":"Get summary of entities to be deleted when removing a folder","guidance":["PREVIEW what deleting a folder will remove before calling rm","Provides a summary of all entities (test cases, recordings, sub-folders) that would be deleted if the folder is removed"],"returns":["active_test_case_count","archived_test_case_count","sub_folders","test_recordings_count"]},{"method":"GET","path":"/api/v1/projects/{project_id}/repository/mapping","mode":"read","entity":"folder","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Get the project's folder tree (v1).","guidance":["the whole project folder TREE in one call (structure array)"],"shape":"discovered"},{"method":"GET","path":"/api/v1/group/users-v2","mode":"read","entity":"user","query":[{"name":"q","type":"string"}],"intent":"Get group users V2 with pagination","guidance":["Retrieve a paginated list of users in the group with search and filtering capabilities, WITHOUT project scoping","used by the Global Search filter dropdowns","Search by user IDs (comma-separated) or by a name string"],"returns":["id","browserstack_user_id","email","full_name","group_id","onboarded","reports_and_notification_enabled"]},{"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":"Get issues count analytics","guidance":["Retrieve the count of issues for a specific project"],"returns":["label"]},{"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","guidance":["so for an integer id this is the right read","Do NOT translate first just to fetch the project","It serves two jobs at once: (1) READ","returns the project's full detail (name, description, permissions, \u2026)","In other words it is NOT translation-only"],"returns":["id","identifier","name","created_at","description","jira_mapped","starred","test_cases_count","test_plans_count","test_runs_count","thProjectId","user_role"]},{"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}],"intent":"Get project-specific custom fields V2 for test cases","guidance":["START HERE for CUSTOM fields","Does NOT expose system-field option ids","Retrieve custom fields, default fields, and system fields for test cases in a specific project (V2 format)"],"returns":["id","default_value","entity_type","field_name","field_type","group_id","is_bulk_editable","is_filterable","is_required","linked_projects_count","place_holder_text","system_name"]},{"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"]}],"intent":"Get project settings","guidance":["Returns an array of enabled setting keys"],"returns":["in_review_count","test_plan_in_review_count"]},{"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"}],"intent":"Get project users V2 with pagination","guidance":["Retrieve paginated list of users in the group with search and filtering capabilities","Search by user IDs (comma-separated) or by name string"],"returns":["id","browserstack_user_id","email","full_name","group_id","onboarded","reports_and_notification_enabled"]},{"method":"GET","path":"/api/v1/projects/basic","mode":"read","entity":"project","query":[{"name":"p","type":"integer"},{"name":"count","type":"integer"},{"name":"q[query]","type":"string","example":"nishchay3"},{"name":"q[identifier]","type":"string","example":"PR-60863"}],"intent":"Get paginated projects (lean payload) \u2014 use this to list or count projects","guidance":["so you can reach a true total without truncating","Those two are the only recognised keys"],"returns":["id","name","description","import_id","normalisedName","starred","thProjectId"],"max_page_size":300,"paginated":true},{"method":"GET","path":"/api/v1/projects/minify","mode":"read","entity":"project","query":[{"name":"p","type":"integer"},{"name":"count","type":"integer"}],"intent":"Get all projects (minified dropdown) \u2014 has NO name search","guidance":["never use it to resolve a named project or to count them","Minified list of active projects, for dropdown-style selection","It accepts no q in any form","the backend call has no query option","so it can only page through everything","Do NOT use it to resolve a project the user named"],"returns":["id","name","description","import_id","normalisedName","starred","thProjectId"],"max_page_size":300,"paginated":true},{"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},{"name":"attachment_id","type":"integer","required":true}],"intent":"Get download URL for a report attachment","guidance":["Retrieve a secure URL to download a specific report attachment"],"returns":["url"]},{"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"},{"name":"report_data_only","type":"boolean"},{"name":"p","type":"integer"},{"name":"today","type":"string","example":"2025-05-13"}],"intent":"Retrieve a scheduled report's config and data \u2014 the general report read","guidance":["works for every report type","Full configuration plus computed data for a report of ANY type","request the section and look at the result","which is a real finding","Pass sections to compute the sections you need","several in ONE call, cheaper than one request per section"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"],"paginated":true},{"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"}],"query":[{"name":"widget_type","type":"string"},{"name":"segment","type":"string"},{"name":"p","type":"integer"}],"intent":"Fetch one report widget/section \u2014 works for EVERY report type.","guidance":["Section names are validated per type","*_drilldown sections return paginated ROWS, not aggregates","Returns a single section's data for any report type","This is the general section read","so when you need SEVERAL sections, prefer that form with a comma-separated sections list and take them in one call rather than one request per section","Most sections return the computed aggregate for a widget"],"shape":"discovered","paginated":true},{"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","requirement_traceability_report"]}],"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":"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`"},{"name":"sections","type":"string","example":"test_run_data,test_run_status"},{"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"]},{"name":"segment","type":"string"},{"name":"p","type":"integer"}],"intent":"Get summary statistics for a scheduled report \u2014 run and traceability types ONLY","guidance":["400s on every other type","so not for Test Case Activity or Test Plan Summary","including every other type in ReportType","This is not the general report-read","optionally with sections","reportTimeRange is required"],"shape":"discovered","paginated":true},{"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 of the folder to search for"},{"name":"include_folder_hierarchy","type":"boolean","description":"Whether to include folder hierarchy in the response"},{"name":"p","type":"integer"},{"name":"per_page","type":"integer"}],"intent":"Get all folders and subfolders present in the projects.","guidance":["list root folders (paged with p","Returns all folders and subfolders in a project if it exists in TestHub"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"],"max_page_size":100,"paginated":true},{"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":"Get already selected testcases for a tracebility report","guidance":["Defects Summary and Defects Detailed Report are accepted on create but have no data branch","AND it requires reportTimeRange: omitting it is a 500, not a 400","Section names are per-report-type","a name from another type is a 400","so one bad name rejects the whole call without saying which","See the reports concept for the per-type lists"],"returns":["select_all","unique_test_case_count"]},{"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":"paginated","type":"boolean"},{"name":"p","type":"integer"}],"intent":"Get shared steps","guidance":["read one shared step with its ordered step details","Retrieves a list of shared steps for a project"],"returns":["id","title"],"paginated":true},{"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"},{"name":"q[query]","type":"string"},{"name":"pre_fetch","type":"boolean"}],"intent":"Get all shared steps for a project","guidance":["Returns a list of all shared steps within a project"],"returns":["id","title","step_count","test_case_count"],"paginated":true},{"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"},{"name":"p","type":"integer"}],"intent":"THE source for a system field's option ids (priority/status/case_type/automation_state).","guidance":["a single attachment-URL field is enough to push the response past the ~30 KB cap","Supports q to search the option list and p to page it, which matters","a workspace can accumulate dozens of options on a single field"],"shape":"discovered","paginated":true},{"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":"Get all attachments for a test case in a folder in a project","guidance":["list a case's attachments","Retrieve all attachments associated with a specific test case within a folder","CURRENTLY RETURNING 500","a reproducible server error, not a bad request","Do NOT retry it and do not report it to the user as their mistake"],"returns":["id","byte_size","content_type","filename"]},{"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":"Get a test case by INTEGER id (v1, folder-scoped) \u2014 full detail + its TC-NNN identifier","guidance":["so for an integer id this v1 read is the ONLY way to fetch the case","Two jobs at once: (1) READ","NOT translation-only","Don't fall back to listing the folder to find the case: if you have the integer id, read it here directly"],"returns":["id","identifier","title"]},{"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":"Get test case count trend analytics","guidance":["Retrieve the trend of test case counts for a specific project"],"returns":["field"]},{"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","example":"folder_path"}],"intent":"Get Test Case Details","guidance":["full detail (steps, custom fields, issues) before editing","read this, improve, then write back"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"Get test case histories","guidance":["list all version records for a test case","Retrieve all history records for a specific test case"],"returns":["id","comment","created_at","entity_id","entity_type","group_id","project_id","source","user_id","version_id","version_name"]},{"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}],"intent":"Get test case history diff","guidance":["compare two versions","versions[] with exactly two ids (PAID plan)","Retrieve the diff of a specific history record for a test case"],"returns":["id","order","result","shared_step_id","step"]},{"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":"Get a specific test case history","guidance":["Retrieve details of a specific test case history by its ID"],"returns":["id","comment","created_at","entity_id","entity_type","group_id","project_id","source","user_id","version_id","version_name"]},{"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":"Get tags and metadata for a test case","guidance":["read a case's current tags","Retrieve the tags and metadata associated with a specific test case in a project"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"Get test case type split analytics","guidance":["Retrieve the split of test case types for a specific project"],"returns":["name","field","value","y"]},{"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"}],"query":[{"name":"required[fields]","type":"string","values":["count"]}],"intent":"Get paginated test cases for a test run","guidance":["page the cases in a run (each row is the case you log results against)","Retrieve a paginated list of test cases for a specific test run in a project"],"shape":"discovered"},{"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"},{"name":"per_page","type":"integer"},{"name":"required[fields]","type":"string","example":"owner,priority"},{"name":"required[custom_fields]","type":"string","example":"121,119"},{"name":"all_folders","type":"string","values":["true"],"example":"true"}],"intent":"List a project's test cases \u2014 REQUIRES all_folders=true to be project-wide.","guidance":["Without all_folders=true this returns only ONE folder's cases","the project's first root folder","So a project-wide question answered from a bare call silently under-counts by whatever sits in every other folder, and nothing in the response says so","all_folders is compared as the STRING 'true' (all_folders = params['all_folders'] == 'true')","A JSON boolean true, 1, TRUE or any other value all fail that check and silently fall back to the single-folder scope"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"max_page_size":100,"paginated":true},{"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":"Get a test plan by INTEGER id (v1) \u2014 full detail + its TP-NNN identifier","guidance":["READ one plan by INTEGER id","Read a test plan by its INTEGER id (project integer id in the path)","Don't translate first just to read the plan","this returns its full detail directly","Two jobs at once: (1) READ","the plan's full detail (under test_plan"],"returns":["identifier","name"]},{"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"},{"name":"test_run_id","type":"integer"},{"name":"today","type":"string"}],"intent":"Execution-trend chart data for ONE plan or ONE run (XOR)","guidance":["pass test_plan_id XOR test_run_id (both or neither is a 400)","Time-series execution trend backing the Insights tab chart","Scope it with exactly one of test_plan_id or test_run_id","Passing both returns 400 \"Provide either test_plan_id or test_run_id, not both\"","passing neither returns 400 \"test_plan_id or test_run_id is required\"","This returns chart data, not a widget object"],"shape":"discovered"},{"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 a test plan","guidance":["the other half of the Insights tab","Like execution-trend, this is chart data only","insights widgets themselves are not a CRUD resource here"],"shape":"discovered"},{"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"},{"name":"include_sub_test_plan_runs","type":"boolean"},{"name":"compact","type":"boolean"},{"name":"is_archived","type":"boolean"}],"intent":"List the test runs linked to a test plan","guidance":["Paginated list of the runs a plan groups","that field replaces the link set rather than appending to it","include_sub_test_plan_runs=true also returns runs belonging to the plan's sub-plans"],"shape":"discovered","paginated":true},{"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}],"query":[{"name":"p","type":"integer"},{"name":"per_page","type":"integer"}],"intent":"Get paginated custom fields for test results","guidance":["Retrieve paginated list of custom fields configured for test results in a project"],"returns":["id","default_value","entity_type","field_name","field_type","field_user_name","is_bulk_editable","is_filterable","is_required","optional","placeholder"],"max_page_size":100,"paginated":true},{"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"},{"name":"test_case_id","type":"integer","required":true}],"intent":"Get test results for a test case in a test run","guidance":["read a case's results within a run","Retrieve a list of test results for a specific test case within a test run in a project"],"returns":["id","status","backtrace","configuration_id","created_at","created_by_imported","custom_fields","description","error_description","expanded","failure_type","finished_at"]},{"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":["Read a test run by its INTEGER id (with the project's integer id in the path)","Don't translate first just to read the run","this returns its full detail directly","Two jobs at once: (1) READ","NOT translation-only"],"returns":["id","identifier","name","uuid"]},{"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},{"name":"test_run_id","type":"string","required":true}],"intent":"Get a run with its per-case rows (v1).","guidance":["the run with its per-case rows (did it pass?)"],"shape":"discovered"},{"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"}],"intent":"Get custom field values for test results in test runs","guidance":["Retrieve default field values and optionally custom fields for test results (TRTC - Test Run Test Case)"],"returns":["id","applies_to_all_projects","default_value","entity_type","field_name","field_type","is_required","link_to_future_projects","place_holder_text"]},{"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.","json_path":"/selection/select_all"},{"name":"unique_test_case_count","type":"integer","example":2,"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.","json_path":"/selection/folders"},{"name":"shared_selection","type":"object"}],"intent":"get selected test runs of a project","guidance":["Retrieve selected test runs for a specific project based on the provided selection criteria"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"ignore_run_type","type":"boolean"},{"name":"include_test_plan","type":"boolean"}],"intent":"Get all the test runs for a project","guidance":["list the project's (open) runs, paged with p","Retrieve a list of all test runs for a specific project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"Import a CSV dataset \u2014 NOT SUPPORTED in this profile","guidance":["say CSV import isn't available","CSV import is not supported here","If asked to import a dataset from CSV, say it isn't available and point to the Test Management UI"]},{"method":"GET","path":"/api/v1/activities","mode":"read","entity":"activity","query":[{"name":"project_id","type":"integer"},{"name":"cursor","type":"string"},{"name":"limit","type":"integer"},{"name":"actor_id","type":"integer"},{"name":"starred_only","type":"boolean"},{"name":"entity_type","type":"array"}],"intent":"Read the activity feed (audit trail) \u2014 CURSOR paged, 15-day window","guidance":["WHO changed WHAT recently","group-wide audit trail","Group-wide audit trail of who changed what","Read-only: activity events cannot be created, edited, or deleted","Two things differ from every other list in this profile: - Cursor pagination, not page numbers","so you cannot jump to a page or read a total"],"returns":["no_starred_projects"]},{"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"}],"intent":"List archived test plans","guidance":["where a 'missing' plan usually is, and the source of ids for bulk-retrieve","Paginated list of the project's archived plans","so if a plan the user names seems missing, look here before concluding it was deleted"],"shape":"discovered","paginated":true},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"List a dataset's options with their ids.","guidance":["the ids a filter or a test-case write needs"],"shape":"discovered"},{"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"},{"name":"q[uuid]","type":"string"},{"name":"p","type":"integer"}],"intent":"List datasets for a project.","guidance":["list datasets (paged p","Retrieve a paginated list of datasets for a project with optional filtering by name or UUID"],"returns":["name","created_at","rows_count","test_cases_count","updated_at","uuid"],"paginated":true},{"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"},{"name":"entity_type","type":"string"},{"name":"entity_id","type":"integer"},{"name":"duplicates","type":"string"}],"intent":"List AI-suggested duplicate test cases \u2014 an empty list may mean the feature is OFF","guidance":["LIST the duplicate suggestions awaiting review","List the AI dedupe recommendations awaiting review in a project","Only duplicates with status suggested are returned","once merged, archived, or discarded they leave this list","An empty result is ambiguous","identical to \"this project has no duplicates\""],"shape":"discovered","paginated":true},{"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":"page","type":"integer","example":1},{"name":"per_page","type":"integer","example":30}],"intent":"List defects for an exploratory session","guidance":["List defaults to active sessions","the parent project takes the INTEGER project id (v1)","defects are the issues linked to the session"],"returns":["issue_id","issue_type"],"max_page_size":100,"paginated":true},{"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":"page","type":"integer","example":1},{"name":"per_page","type":"integer","example":50}],"intent":"List logs for an exploratory session","guidance":["page the session's log entries","Returns a paginated list of log entries for the specified exploratory session"],"returns":["id","status","content","created_at","elapsed_time","session_id","updated_at"],"max_page_size":100,"paginated":true},{"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":"page","type":"integer","example":1},{"name":"per_page","type":"integer","example":30},{"name":"q[session_state]","type":"string","values":["active","closed"],"example":"active"},{"name":"q[assignee]","type":"integer","example":42},{"name":"q[owner]","type":"integer","example":42},{"name":"q[created_by]","type":"integer","example":10},{"name":"q[created_at_from]","type":"string","example":"2026-01-01"},{"name":"q[created_at_to]","type":"string","example":"2026-01-31"},{"name":"q[tags][]","type":"array","example":["regression","payment"]},{"name":"q[configuration_ids][]","type":"array","example":[1,2]},{"name":"q[search]","type":"string","example":"checkout"},{"name":"q[status]","type":"string","example":"pass"}],"intent":"List exploratory sessions for a project","guidance":["list sessions (defaults to active","Returns a paginated list of exploratory sessions"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"],"max_page_size":100,"paginated":true},{"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","type":"string","required":true,"values":["testcase","testplan","testrun","exploratory_session","test_plan_test_case"]},{"name":"page","type":"integer"}],"intent":"List filters","guidance":["list saved filter views (optional entity = testcase|testplan|testrun|exploratory_session)","Retrieve a paginated list of saved filters for a project, optionally filtered by entity type"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"],"paginated":true},{"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":"List subfolders and folder metadata for a specific folder in a project","guidance":["one folder's detail + its direct subfolders + ancestors"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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"},{"name":"per_page","type":"integer"},{"name":"required[fields]","type":"string","example":"owner,priority"},{"name":"required[custom_fields]","type":"string","example":"121,119"}],"intent":"List the test cases in one folder","guidance":["Page through the cases directly inside a folder, by the folder's INTEGER id","Use this when the user has already named or selected a folder","Rows are verbose and list reads truncate around 30 KB"],"shape":"discovered","max_page_size":100,"paginated":true},{"method":"GET","path":"/api/v1/projects","mode":"read","entity":"project","query":[{"name":"q","type":"string","example":"nishchay3"},{"name":"p","type":"integer"},{"name":"sort_by","type":"string"},{"name":"starred","type":"boolean"},{"name":"shared","type":"boolean"},{"name":"is_hidden_projects","type":"boolean","example":true}],"intent":"List projects for a group.","guidance":["Paginated list of the group's projects","query and page are not read by the server","so use this to FIND a project, not to enumerate them"],"returns":["id","identifier","name","created_at","description","import_id","project_creator","test_cases_count","test_runs_count"],"paginated":true},{"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"},{"name":"q[scheduled]","type":"string","values":["true","false"]},{"name":"p","type":"integer"}],"intent":"List all the scheduled reports","guidance":["list the project's report schedules (paged p","Retrieve a paginated list of schedules"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"],"paginated":true},{"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"},{"name":"q","type":"string"}],"intent":"List test-case tags (paginated).","guidance":["list a project's test-case tags (paged with p, optional q)"],"shape":"discovered","paginated":true},{"method":"GET","path":"/api/v1/admin-v2/settings/templates","mode":"read","entity":"","query":[{"name":"entity_type","type":"string","values":["TestCase","TestResult","TestPlan","ExploratorySession"]},{"name":"project","type":"integer"},{"name":"name","type":"string"},{"name":"p","type":"integer"}],"intent":"List the workspace's test-case templates \u2014 THE source for `template_id`.","guidance":["Ids are per-workspace","so one carried over from another workspace (or from an example) produces the generic 400 \"The API request is invalid or improperly formed\" on a test-case create, with nothing naming the offending field","Call this when the user asked for a named template, or to confirm a template id before reusing one","One call therefore yields both fields","NOTE THE CASING: entity_type here is CamelCase (TestCase), unlike the hyphenated test-case used in path segments elsewhere","Passing test-case returns an empty list with a 200"],"shape":"discovered","paginated":true},{"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"},{"name":"include_sub_plans","type":"boolean"},{"name":"status","type":"string","values":["new","started","completed"]},{"name":"sort_by","type":"string"},{"name":"minify","type":"boolean"}],"intent":"List test plans in a project (optionally including sub-plans)","guidance":["include_sub_plans=true to see sub-plans too","Paginated list of the project's test plans","This is how you find a plan's integer id before reading, updating, or deleting it","so never try to find a plan through search","Without it you see only top-level plans and a plan's children are invisible"],"shape":"discovered","paginated":true},{"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"]},{"name":"field_source","type":"string","values":["system","custom","all"]},{"name":"field_type","type":"string"},{"name":"q","type":"string"},{"name":"p","type":"integer"}],"intent":"THE canonical workspace field list (system + custom) \u2014 start here for definitions.","guidance":["workspace-wide list of DEFINITIONS across all projects","'does a field called X exist anywhere?'","The authoritative list (testhub-backed)","This is the authoritative definition list: it is backed by testhub, which owns custom-field definitions","That legacy collection reads a DEPRECATED table local to the Rails app that nothing else consults","its contents are unrelated to the real fields"],"shape":"discovered","paginated":true},{"method":"POST","path":"/api/v1/projects/{project_id}/tags/merge","mode":"write","entity":"tag","path_params":[{"name":"project_id","type":"integer","required":true}],"body":[{"name":"source_id","type":"integer","required":true},{"name":"target_id","type":"integer","required":true},{"name":"entity_type","type":"string","required":true,"values":["test_case","test_run","test_plan","shared_step"]}],"intent":"Merge one tag into another (v1). Admin-gated (tags_management).","guidance":["merge one tag into another ({ source_id, target_id, entity_type })"]},{"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","required":true,"example":123,"description":"ID of the new parent folder (internal APIs)"},{"name":"new_base_project_id","type":"integer","example":456,"description":"Optional destination project ID for cross-project moves"},{"name":"user_action","type":"string","example":"move_folder"}],"intent":"Move a test case folder to a different parent folder","guidance":["move a folder ({ new_base_folder_id, new_base_project_id })","cross-project moves run async","Move a test case folder to a new parent folder within the same project","This operation updates the folder's hierarchy without changing its contents"],"returns":["async","unique_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","required":true,"description":"Name of the new folder.","json_path":"/folder/name"},{"name":"notes","type":"string","description":"Description of the new folder.","json_path":"/folder/notes"}],"intent":"rename a folder in a project","guidance":["Rename an existing folder within a specific project"],"returns":["request_trace_id"]},{"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],"description":"List of folder IDs to reorder"},{"name":"prev","type":"integer","example":101,"description":"ID of the folder that will precede the moved folder"},{"name":"next","type":"integer","example":103,"description":"ID of the folder that will follow the moved folder"}],"intent":"Reorder folders in a project","guidance":["Reorder folders within a project by specifying the current order and optionally the previous and next folder IDs"],"returns":["request_trace_id"]},{"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":"ID of the test case that will be above the moved ones.","json_path":"/re_order/top_test_case"},{"name":"bottom_test_case","type":"string","description":"ID of the test case that will be below the moved ones.","json_path":"/re_order/bottom_test_case"},{"name":"page","type":"integer","description":"Page number for paginated reordering.","json_path":"/re_order/page"},{"name":"test_cases","type":"array","required":true,"description":"Array of test case IDs to be moved.","json_path":"/re_order/test_cases"}],"intent":"Reorder test cases within a folder of a project","guidance":["Reorders test cases in a folder by placing them between top_test_case and bottom_test_case"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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. Passed in the BODY, not the path."},{"name":"merge_action","type":"string","required":true,"description":"`merge` folds source into target; any other value archives."},{"name":"source_testcase_id","type":"integer","description":"Required for merge \u2014 the case that will cease to exist independently."},{"name":"target_testcase_id","type":"integer","description":"Required for merge \u2014 the case that survives."}],"intent":"Resolve a duplicate by MERGING or ARCHIVING \u2014 destroys or hides a test case","guidance":["RESOLVE a suggestion","merge_action=merge folds source_testcase_id into target_testcase_id (DESTRUCTIVE to a test case), anything else archives","duplicate_id goes in the BODY","Act on one dedupe recommendation","merge_action selects what happens: - \"merge\"","folds source_testcase_id into target_testcase_id"]},{"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":"Restore a specific history entry","guidance":["roll the case back to this version (PAID plan","Restore a specific history entry by its ID"]},{"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":"page","type":"integer"},{"name":"duplicates","type":"string","description":"Duplicate-type filter."},{"name":"owner","type":"string","description":"Owner tab \u2014 scopes to the caller's own duplicates vs all."}],"intent":"Filtered search over suggested duplicates","guidance":["filtered search over suggestions (owner tab, duplicate type, test-case attributes)","Subject to the identical flag caveat: an empty list may mean the feature is off rather than that there are no duplicates"],"shape":"discovered","paginated":true},{"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"}],"query":[{"name":"q","type":"string"},{"name":"p","type":"integer"}],"intent":"Search group tags for an entity type","guidance":["search tags across the whole group (entity_type = test-case|test-run|test-plan)","Retrieve a paginated list of tags for the given entity type across the group (no project scoping)","used by the Global Search filter dropdowns"],"returns":["name","created_at","usage_count"],"paginated":true},{"method":"GET","path":"/api/v1/projects/{project_id}/{entity}/search","mode":"read","entity":"project","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"entity","type":"string","required":true,"values":["test-cases","test-runs","test-plans","reports"]}],"query":[{"name":"q[query]","type":"string","example":"tc"},{"name":"q[folder_id]","type":"integer","example":29529465},{"name":"use_bstack_id","type":"string","values":["0","1"]},{"name":"p","type":"integer"},{"name":"page_size","type":"integer"},{"name":"include_test_plan","type":"boolean"}],"intent":"Search for entities within a project","guidance":["Returns paginated results matching the search criteria"],"shape":"discovered","max_page_size":100,"paginated":true},{"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"},{"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 "},{"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":["Note: Array values in the q parameter are automatically converted to comma-separated strings for compatibility with existing search logic"],"shape":"discovered","max_page_size":100,"paginated":true},{"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"},{"name":"p","type":"integer"},{"name":"page_size","type":"integer"}],"intent":"Search users with access to a project","guidance":["Retrieves a paginated list of users who have access to the specified project","Returns user details including permissions, feature flags, and product roles"],"returns":["id","browserstack_user_id","customisable_dashboard_accessible","full_name","group_id","has_access","is_build_listing_enabled","is_delighted_visible","is_jira_app_project_panel_visible","is_lcnc_explore_dismissed","is_new_project_settings_visible","is_onboarding_project_header_visible"],"max_page_size":100,"paginated":true},{"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"},{"name":"p","type":"integer"},{"name":"page_size","type":"integer"}],"intent":"Search test case tags","guidance":["Retrieves a paginated list of tags associated with test cases in the project","Returns both simple tag strings and detailed tag objects with metadata"],"returns":["name","created_at","usage_count"],"max_page_size":100,"paginated":true},{"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":"query","type":"string"},{"name":"use_bstack_id","type":"string","values":["0","1"]}],"intent":"Search test cases in a project","guidance":["and see pagination-and-filtering for the key table and each value's source","there is NO top-level values array, and looking for one finds nothing","so it is routinely cut off and appears absent","NEVER probe candidate integers for an id","The four system option fields","a name silently returns 0 because the server matches an id column"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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","example":["qa-leads@company.com"],"description":"Recipient addresses. MUST be an array, even for one address."},{"name":"file_type","type":"array","example":["pdf"],"description":"Formats to attach. MUST be an array. Defaults to [\"pdf\"]."}],"intent":"Email a report immediately (async job).","guidance":["Kicks off a background send","a 200 means the job was queued, NOT that mail was delivered","don't report it as sent","Both body fields are ARRAYS and are rejected with 400 \" should be an array\" if sent as scalars","Omitting emails sends to the report's configured recipients","omitting file_type defaults to [\"pdf\"]"]},{"method":"POST","path":"/api/v1/projects/{project_id}/folder/{folder_id}/undo-rm","mode":"write","entity":"folder","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"folder_id","type":"integer","required":true}],"intent":"Undo folder deletion","guidance":["Restores a previously deleted folder and returns its metadata along with path hierarchy"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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":"dataset_id","type":"integer","required":true},{"name":"option_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"body":[{"name":"option_value","type":"string","required":true,"description":"The option label."},{"name":"is_default","type":"boolean","required":true,"description":"REQUIRED \u2014 a missing value is a 400. Must be 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 parent option this hangs under."}],"intent":"Rename an option or change its default (POST to /update).","guidance":["NON-destructive, stored values follow the option id","Both option_value and is_default are required","a missing is_default is a 400","Renaming an option keeps the stored values pointing at it (the option id is unchanged)","so this is the NON-destructive way to fix a label"]},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"body":[{"name":"link_projects","type":"array","description":"Project INTEGER ids to ADD. Note the spelling \u2014 not `linked_projects`."},{"name":"unlink_projects","type":"array","description":"Project INTEGER ids to REMOVE. Destroys their stored values."},{"name":"link_to_future_projects","type":"boolean"},{"name":"select_all","type":"boolean","description":"Apply to every project; may switch the call to async."}],"intent":"Link or unlink projects for a dataset \u2014 how you change which projects see a field.","guidance":["Unlinking destroys that project's values","The keys here are link_projects and unlink_projects","Send the wrong spelling and it is silently dropped: 200, nothing changed","This is the single easiest mistake on this surface","These are DELTAS, not the complete new list: send only the projects to add and the projects to remove","Unlinking is destructive"]},{"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,"description":"Display label. Editable. REQUIRED even when unchanged."},{"name":"field_type","type":"string","required":true,"description":"REQUIRED for validation, but any change is silently ignored."},{"name":"is_required","type":"boolean","required":true,"description":"REQUIRED \u2014 omitting it is a 400."},{"name":"entity_type","type":"string"},{"name":"place_holder_text","type":"string"},{"name":"default_value","type":"string","description":"Only applied when `field_type` is `boolean`."}],"intent":"Update a field definition's label, requiredness or placeholder (POST to /update).","guidance":["Full replace: field_name + field_type + is_required all required","field_type changes are silently ignored","field_name, field_type and is_required must all be present","Omitting is_required is 400 \"Invalid Params\"","omitting field_name is a 500","field_name IS editable here"]},{"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","description":"Updated name of the dataset.","json_path":"/dataset/name"},{"name":"variables","type":"array","description":"Updated list of dataset variables/columns (max 40).","fields":[{"name":"name","type":"string","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/variables"},{"name":"rows","type":"array","description":"Updated list of dataset rows (max 100).","fields":[{"name":"row_number","type":"integer","required":true},{"name":"row_data","type":"array","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/rows"}],"intent":"Update a dataset.","guidance":["Update an existing dataset by its UUID with new variables and rows"],"returns":["name","created_at","rows_count","uuid"]},{"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 for the session.","json_path":"/exploratory_session/title"},{"name":"description","type":"string","description":"Updated description.","json_path":"/exploratory_session/description"},{"name":"charter","type":"string","description":"Updated testing charter.","json_path":"/exploratory_session/charter"},{"name":"timebox_duration","type":"integer","example":90,"description":"Updated planned duration in minutes.","json_path":"/exploratory_session/timebox_duration"},{"name":"duration","type":"integer","example":45,"description":"Tracked duration in minutes.","json_path":"/exploratory_session/duration"},{"name":"actual_duration","type":"integer","example":50,"description":"Final actual duration in minutes.","json_path":"/exploratory_session/actual_duration"},{"name":"folder_id","type":"integer","example":789,"description":"Move the session to a different folder.","json_path":"/exploratory_session/folder_id"},{"name":"owner","type":"integer","example":55,"description":"TCM user ID of the new owner / assignee.","json_path":"/exploratory_session/owner"},{"name":"assignee","type":"integer","example":55,"description":"Alias for `owner`.","json_path":"/exploratory_session/assignee"},{"name":"tags","type":"array","example":["smoke","payment"],"description":"Replace the session's tags with this list.","json_path":"/exploratory_session/tags"},{"name":"configurations","type":"array","example":[2,4],"description":"Replace the session's configuration IDs.","json_path":"/exploratory_session/configurations"},{"name":"attachments","type":"array","description":"Updated set of blob / media attachment IDs.","json_path":"/exploratory_session/attachments"},{"name":"issues","type":"array","description":"Replace the linked issues for this session.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/exploratory_session/issues"}],"intent":"Update an exploratory session","guidance":["edit a session (title, charter, timebox_duration, duration, owner, tags, ...)","Updates one or more fields of an exploratory session","Time fields (timebox_duration, duration, actual_duration) must be non-negative integers not exceeding 2147483647"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"]},{"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":"Updated rich-text HTML content of the log entry.","json_path":"/session_log/content"},{"name":"status","type":"string","values":["pass","fail","bug","retest","block","note"],"example":"fail","description":"Updated test result status.","json_path":"/session_log/status"},{"name":"elapsed_time","type":"integer","example":180,"description":"Updated elapsed time in seconds.","json_path":"/session_log/elapsed_time"},{"name":"defects","type":"array","description":"Replace the linked defects for this log entry.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/session_log/defects"}],"intent":"Update a session log entry","guidance":["Updates an existing log entry within an exploratory session","Rich-text HTML content is sanitised before storage"],"returns":["id","status","content","created_at","elapsed_time","session_id","updated_at"]},{"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,"description":"Name of the filter"},{"name":"entity","type":"string","required":true,"values":["testcase","testplan","testrun","exploratory_session","test_plan_test_case"],"description":"Entity type the filter applies to. The server's validator accepts five values (the\nlast two were missing here); an unlisted value returns 400 \"Invalid JSON data\"."},{"name":"is_project_level","type":"boolean","description":"Whether this filter is available at project level"},{"name":"filters","type":"object","description":"Filter criteria object containing the actual filter conditions"}],"intent":"Update a filter","guidance":["Update an existing saved filter with new configuration or filter criteria"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"]},{"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"},{"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 these are ids, not names).","json_path":"/test_result/status_id"},{"name":"description","type":"string","description":"Rich text description of the test result.","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's 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":"Update the latest test result","guidance":["update the LATEST result for a case in a run (partial, same body shape)","Update the most recent test result for a specific test case within a test run in a project","Optionally filter by configuration_id or mapping_id"],"returns":["id","name","byte_size","checksum","content_type","download_url","key","product_id","size","url"]},{"method":"POST","path":"/api/v1/projects/{project_id}/settings","mode":"write","entity":"project","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Update project settings","guidance":["Accepts an object with setting keys as properties and boolean values"]},{"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","example":10000},{"name":"report_type","type":"string","required":true,"values":["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","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_statu"},{"name":"title","type":"string","example":"Updated Title"},{"name":"description","type":"string","example":""},{"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 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`"},{"name":"report_creation_mode","type":"string","required":true,"values":["custom_report","system_generated"],"example":"custom_report"},{"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"},{"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":"Integer run ids \u2014 pairs with report_timeframe=specific_test_runs.","json_path":"/report_filters/test_runs"},{"name":"test_plans","type":"array","description":"Integer plan ids \u2014 pairs with report_timeframe=specific_test_plans.","json_path":"/report_filters/test_plans"},{"name":"test_cases","type":"string","description":"Case selection \u2014 pairs with report_timeframe=specific_test_cases. FOLDER-KEYED\n(see SelectionData), never a flat id list: the server resolves the selection\nthrough its folder map, so any other shape yields zero cases and saves an\nUNSCOPED, project-wide report at 200.\n\nSend all three keys. Folder ids are STRING keys; test-case ids are INTEGERS\n(the numeric `id`, not the TC-NNN identifier) \u2014 take bo","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":"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.","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 } }.","json_path":"/report_filters/test_plan_selection"},{"name":"builds","type":"array","json_path":"/report_filters/builds"},{"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\n`Test Run Summary`, `Test Run Detailed Report` and `Test Case Activity` \u2014 on\nthe other six report types the server never reads it and drops it silently.\nPersisted (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`."},{"name":"users","type":"array","json_path":"/mail_to/users"},{"name":"external_mails","type":"array","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\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"},{"name":"time","type":"string","example":"08:00","description":"24-hour HH:MM, UTC.","json_path":"/frequency_details/time"},{"name":"day","type":"string","example":"monday","description":"Required for weekly (lowercase day name) and monthly (integer 1-28).\nOmit for daily.","json_path":"/frequency_details/day"}],"intent":"Update a scheduled report","guidance":["FULL REPLACE, not a delta: omitted fields are nulled and dropping frequency cancels the schedule","Update the configuration of an existing scheduled report for a project"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"]},{"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":"Title of the shared step"},{"name":"folder_id","type":"integer","description":"ID of the shared-field folder to move the shared step into. Null moves it to the root (Unassigned)."},{"name":"shared_step_details","type":"array","required":true,"fields":[{"name":"id","type":"integer"},{"name":"step","type":"string"},{"name":"result","type":"string"},{"name":"order","type":"integer"},{"name":"test_data","type":"string"}]}],"intent":"Update a shared step","guidance":["title and shared_step_details are BOTH required, and the details array REPLACES the existing steps","Updates an existing shared step in a project"],"returns":["id","title"]},{"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"},{"name":"description","type":"string","json_path":"/test_plan/description"},{"name":"start_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/start_date"},{"name":"end_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/end_date"},{"name":"plan_status","type":"string","description":"Plan status. The list filter accepts new / started / completed.","json_path":"/test_plan/plan_status"},{"name":"owner","type":"integer","description":"Owner user id \u2014 resolve via the project users read first.","json_path":"/test_plan/owner"},{"name":"parent_plan_id","type":"integer","description":"Parent plan's INTEGER id. Set it to create (or re-parent into) a SUB-plan \u2014 the\nresult carries an STP-NNN identifier. Null/omitted means a top-level plan.","json_path":"/test_plan/parent_plan_id"},{"name":"tags","type":"array","json_path":"/test_plan/tags"},{"name":"reviewers","type":"array","description":"Reviewer user ids.","json_path":"/test_plan/reviewers"},{"name":"attachments","type":"array","description":"Attachment ids already uploaded via the attachments surface.","json_path":"/test_plan/attachments"},{"name":"custom_fields","type":"object","json_path":"/test_plan/custom_fields"},{"name":"issues","type":"array","description":"Linked tracker issues. Structured \u2014 NOT a flat array of keys.","fields":[{"name":"issue_id","type":"string"},{"name":"issue_type","type":"string"},{"name":"metadata","type":"object"}],"json_path":"/test_plan/issues"},{"name":"test_runs","type":"string","description":"The plan's linked test runs. Accepts EITHER an array of test-run integer ids, OR a\nbulk-selection object for \"every run matching this filter\". On update this REPLACES\nthe existing link set rather than appending.","json_path":"/test_plan/test_runs"}],"intent":"Update a test plan (or sub-plan)","guidance":["Update a plan by its INTEGER id","Takes the same body as create","so it also works on a sub-plan by its own id","clearing it promotes a sub-plan to a top-level plan","do that only when the user actually asked to move it in the hierarchy","Send only the fields you intend to change"]},{"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 AI-generated or AI-processed attachments","guidance":["upload a file as AI CONTEXT (restricted MIME types, smaller size cap) to seed test-case content","Files are stored in S3 with pre-signed URLs","File Storage: Files uploaded to S3 with pre-signed URLs (expires in ~26 minutes)"],"returns":["id","name","content_type","download_url","size","url"]},{"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 generic attachments to a project from rich text editor or other sources","guidance":["UPLOAD file blob(s) (multipart)","Uploads one or more files as generic attachments to a project","Files are stored in S3 and pre-signed URLs are returned for both viewing and downloading"],"returns":["id","name","content_type","download_url","size","url"]},{"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":"Upload one or more attachments to a test case","guidance":["Upload one or more attachments to a specific test case within a folder in a project"],"returns":["id","byte_size","content_type","filename"]},{"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 whose per-test-case / per-run / per-day breakdown is requested."},{"name":"p","type":"integer","example":1,"description":"Page number for paginated drill-down rows."},{"name":"per_page","type":"integer","example":50,"description":"Number of rows per page."}],"intent":"Fetch the User Workload drill-down for a single user","guidance":["User-Workload per-user execution breakdown","Only valid on a report whose type is User Workload Report","every other type returns 400 \"Drill-down is only available for User Workload Reports\""],"shape":"discovered","max_page_size":100,"paginated":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},"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)","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_v1"]},"attachment":{"entity":"attachment","title":"Attachments","aliases":["attachment","file","attachments","blob"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"attached to"},{"entity":"result","via":"attached to"}],"capabilities":["delete_test_case_attachment","get_test_case_attachments_v1","upload_a_i_attachments","upload_generic_attachments","upload_test_case_attachments_v1"]},"configuration":{"entity":"configuration","title":"Configurations","aliases":["config","configuration","environment","browser","os","device"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_run","via":"case status tracked against"}],"capabilities":["create_configuration_v1","get_configurations_v1"]},"custom_field":{"entity":"custom_field","title":"Custom fields & system fields","aliases":["custom field","custom fields","field","system field"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"extends"},{"entity":"result","via":"extends"}],"capabilities":["create_custom_field_dataset_option_v2","create_custom_field_dataset_v2","create_custom_field_definition_v2","delete_custom_field_dataset_option_v2","delete_custom_field_dataset_v2","delete_custom_field_definition_v2","get_custom_field_dataset_v2","get_custom_field_definition_v2","get_custom_field_values_v1","get_project_id_custom_fields_v2_v1","get_test_results_custom_fields_v1","list_custom_field_dataset_options_v2","list_workspace_fields_v2","update_custom_field_dataset_option_v2","update_custom_field_dataset_project_mapping_v2","update_custom_field_definition_v2"]},"dataset":{"entity":"dataset","title":"Dataset","aliases":["datasets","data-driven-data","dds"],"id_convention":"uuid","parents":["project"],"relations":[{"entity":"test_case","via":"drives"},{"entity":"variable","via":"has columns"}],"capabilities":["create_dataset","delete_dataset","get_dataset","get_dataset_linked_test_cases","get_dataset_variables","import_dataset_c_s_v","list_datasets","update_dataset"]},"duplicate":{"entity":"duplicate","title":"Duplicate recommendation (AI dedupe)","aliases":["duplicate","duplicates","dedupe","deduplication","duplicate recommendation","ai duplicates","redundant test cases"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"links the pair it believes are duplicates"}],"capabilities":["discard_duplicate_v1","get_dedupe_status_v1","get_duplicate_source_test_case_v1","get_duplicate_v1","list_duplicates_v1","resolve_duplicate_v1","search_duplicates_v1"]},"exploratory_session":{"entity":"exploratory_session","title":"Exploratory session","aliases":["session","exploratory","et-session"],"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_v1","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"]},"filter":{"entity":"filter","title":"Saved filter view","aliases":["filter","filters","saved view","saved 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"]},"folder":{"entity":"folder","title":"Folder","aliases":["dir","directory"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"folder","via":"nests under"},{"entity":"test_case","via":"organises"}],"capabilities":["copy_folder","create_bulk_test_cases_v1","create_root_folder_v1","create_sub_folder_v1","delete_folder_and_contents","get_folder_remove_summary","get_folder_tree_v1","get_root_folders_v1","list_folder_contents","move_folder_v1","rename_folder_v1","reorder_folders","undo_remove_folder"]},"project":{"entity":"project","title":"Project","aliases":["pr","workspace"],"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_v1","export_dashboard_analytics","get_active_test_runs_info_v1","get_automation_stats_v1","get_closed_test_runs_info_v1","get_closed_test_runs_split_v1","get_entity_filter_details_v1","get_issues_count_info_v1","get_project_by_integer_id_v1","get_project_settings","get_project_users_v2_v1","get_projects_basic_v1","get_projects_minify_v1","get_test_case_count_trend_v1","get_test_case_type_split_v1","list_projects_v1","search_project_entities","update_project_settings"]},"report":{"entity":"report","title":"Report","aliases":["reports","scheduled-report","schedule","analytics-report"],"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","delete_report_schedule","download_report","get_exploratory_session_summary_v1","get_report_attachment_url","get_report_detail","get_report_section_v1","get_report_summary_by_type","get_selected_report_testcases","list_schedules","send_report_email_now_v1","update_report","user_workload_drilldown"]},"result":{"entity":"result","title":"Result","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_v1","create_step_result_v1","create_test_result_for_test_case","delete_test_result_v1","get_test_results_for_test_case","update_latest_test_result_for_test_case"]},"shared_step":{"entity":"shared_step","title":"Shared step","aliases":["shared step","shared steps","reusable step","step template"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"reused by"}],"capabilities":["create_shared_step_v1","delete_shared_step_v1","get_shared_steps_by_i_d_v1","get_shared_steps_v1","update_shared_step_v1"]},"tag":{"entity":"tag","title":"Tag","aliases":["tags","label","labels"],"id_convention":"name","parents":["project"],"relations":[{"entity":"test_case","via":"labels"},{"entity":"test_run","via":"labels"},{"entity":"test_plan","via":"labels"}],"capabilities":["bulk_edit_test_cases_v2","get_test_case_tags","list_test_case_tags_v1","merge_tags_v1","search_group_tags","search_test_case_tags"]},"test_case":{"entity":"test_case","title":"Test case","aliases":["tc","case","test case"],"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":["bulk_archive_test_cases_by_project","bulk_copy_test_cases","bulk_delete_test_cases","bulk_edit_test_cases","bulk_edit_test_cases_in_test_run","bulk_move_test_cases","bulk_retrieve_test_cases","create_test_case_v1","create_test_cases","edit_test_case_partial_v1","edit_test_case_v1","get_archived_test_cases","get_system_field_values_v1","get_test_case_by_integer_id_v1","get_test_case_detail","get_test_cases_v1","list_folder_test_cases_v1","reorder_test_cases_by_folder_v1","search_project_entities_v2","search_test_cases_by_folder_v1"]},"test_plan":{"entity":"test_plan","title":"Test plan (and sub-plan)","aliases":["tp","plan","milestone","sub test plan","subplan","stp"],"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_v1","bulk_retrieve_test_plans_v1","clone_test_plan_v1","count_test_plan_test_runs_v1","create_test_plan_v1","delete_test_plan_v1","get_archived_test_plan_count_v1","get_test_plan_by_integer_id_v1","get_test_plan_execution_trend_v1","get_test_plan_linked_sessions_chart_data_v1","get_test_plan_test_runs_v1","list_archived_test_plans_v1","list_test_plans_v1","update_test_plan_v1"]},"test_run":{"entity":"test_run","title":"Test run","aliases":["tr","run","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":["assign_test_run_owner","bulk_assign_test_cases_to_test_run","clone_test_run","close_test_run_v1","count_run_selection_v1","create_test_run_v1","delete_v1_test_run","edit_test_run","get_closed_test_runs","get_test_cases_for_v1_test_run","get_test_run_by_integer_id_v1","get_test_run_cases_v1","get_test_runs_form_fields_v1","get_test_runs_selection","get_test_runs_v1"]},"user":{"entity":"user","title":"User","aliases":["users","member","assignee","owner"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"project","via":"member of"},{"entity":"test_run","via":"assigned to"},{"entity":"test_case","via":"owns"}],"capabilities":["get_group_users_v2_v1","search_project_users"]},"version":{"entity":"version","title":"Test case version","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":["get_test_case_histories_v1","get_test_case_history_diff_v1","get_test_case_history_v1","restore_history_v1"]}}}}} \ No newline at end of file diff --git a/src/tools/capability-registry/envelope.ts b/src/tools/capability-registry/envelope.ts deleted file mode 100644 index ce28de3..0000000 --- a/src/tools/capability-registry/envelope.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * Telling a record apart from the envelope around it, and what may be published when the - * product declares no schema at all. - * - * Ported from the Python side, where every rule here was learned from a live failure. - */ - -/** Field names that describe the RESPONSE rather than a record. */ -const ENVELOPE_NAMES = new Set([ - "success", "status_code", "self", "info", "meta", "errors", "error", "message", - "page", "page_size", "per_page", "prev", "next", "count", "total", "total_count", - "current_page", "last_page", "has_more", -]); - -const ENVELOPE_PATTERNS = [/^total(_|$)/, /_pages?$/, /^(has|is)_/, /^empty(_|$)/]; - -export function isEnvelopeField(name: string): boolean { - const lowered = (name || "").trim().toLowerCase(); - if (ENVELOPE_NAMES.has(lowered)) return true; - return ENVELOPE_PATTERNS.some((pattern) => pattern.test(lowered)); -} - -/** The subset of a declared `returns` that could plausibly be on a row. */ -export function rowFields(returns: string[] | undefined): string[] { - return (returns || []).filter((name) => !isEnvelopeField(name)); -} - -/** - * Rows found by SHAPE, not by name. - * - * A hardcoded key list was wrong and quietly so: tm uses 30 distinct row-key names across - * its responses (`histories`, `attachments`, `steps`, `path_folders`, `duplicates`, …), and - * a list of 13 missed 24 of them over 35 operations. - */ -const PREFERRED_ITEM_KEYS = ["items", "projects", "test_cases", "data", "results", "folders", - "test_runs", "test_plans", "plans", "reports", "datasets"]; - -/** Object properties that describe the response, so a lone row is never mistaken for one. */ -const ENVELOPE_OBJECT_KEYS = new Set([ - "info", "meta", "links", "pagination", "page_info", "errors", "error", "self", "_links", -]); - -const TOTAL_KEYS = ["total", "total_count", "count"]; - -function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null && !Array.isArray(value); -} - -export function itemsOf(body: unknown): Record[] { - if (Array.isArray(body)) return body.filter(isRecord); - if (!isRecord(body)) return []; - - const candidates = new Map[]>(); - for (const [key, value] of Object.entries(body)) { - if (Array.isArray(value) && value.some(isRecord)) { - candidates.set(key, value.filter(isRecord)); - } - } - if (candidates.size > 0) { - for (const key of PREFERRED_ITEM_KEYS) { - const hit = candidates.get(key); - if (hit) return hit; - } - return candidates.values().next().value as Record[]; - } - - // ANY array-valued property marks the row LOCATION, so an empty one is a genuine empty - // answer. "no rows" must stay distinguishable from "this shape has no rows in it". - if (Object.values(body).some((value) => Array.isArray(value))) return []; - - // A SINGLE-RECORD RESPONSE IS ONE ROW. Without this, every stats/summary/detail read came - // back ok:true, count:0, items:[] — the worst available failure, because it is not an - // error: the product answered fully and the resolver could not see it. - const nested = Object.entries(body).filter( - ([key, value]) => isRecord(value) && Object.keys(value).length > 0 && - !ENVELOPE_OBJECT_KEYS.has(key), - ); - if (nested.length === 1) return [nested[0][1] as Record]; - return [body]; -} - -/** Declared total, if the envelope carries one. tm puts it under `info`. */ -export function totalOf(body: unknown): number | undefined { - if (!isRecord(body)) return undefined; - const containers = [body.info, body.meta, body]; - for (const container of containers) { - if (!isRecord(container)) continue; - for (const key of TOTAL_KEYS) { - const value = container[key]; - if (typeof value === "number" && Number.isInteger(value)) return value; - } - } - return undefined; -} - -// ---- discovery mode --------------------------------------------------------------- -// -// `returns` is an allowlist, which is the right default. But 32 of tm's 173 operations -// declare a bare `{type: object}` response, so there is no field name to allowlist and the -// capability would be excluded — 19% of the surface, including "list test plans". The -// choice is not "safe capability vs unsafe capability", it is "discovered shape vs no -// capability at all". Two limits make it acceptable, and both are load-bearing: -// -// * SCALARS ONLY — a nested object is never expanded, which contains the blast radius of -// an unknown field. The harness records that `assignee` expands to email / full_name / -// browserstack_user_id and that custom-field `field_values` echo signed URLs; both are -// objects, so neither can travel this path. -// * A NAME DENYLIST for the scalars that remain. -const SENSITIVE_MARKERS = [ - "token", "secret", "password", "access_key", "api_key", "apikey", "private", - "credential", "signature", "email", "phone", "browserstack_user_id", "session_id", -]; -const SENSITIVE_EXACT = new Set(["user_id", "group_id"]); - -/** A row is a record, not a table dump. */ -export const MAX_DISCOVERED_FIELDS = 24; - -export function isSensitiveField(name: string): boolean { - const lowered = (name || "").toLowerCase(); - if (SENSITIVE_EXACT.has(lowered)) return true; - return SENSITIVE_MARKERS.some((marker) => lowered.includes(marker)); -} - -export function discoveredFields(row: Record): Record { - const out: Record = {}; - for (const [name, value] of Object.entries(row || {})) { - if (value !== null && typeof value === "object") continue; // scalars only - if (isSensitiveField(name) || isEnvelopeField(name)) continue; - out[name] = value; - if (Object.keys(out).length === MAX_DISCOVERED_FIELDS) break; - } - return out; -} - -/** Keep only the declared fields, or the discovered scalars when nothing is declared. */ -export function projectRow( - row: Record, - returns: string[] | undefined, - discover: boolean, -): Record { - if (returns && returns.length > 0) { - const out: Record = {}; - for (const name of returns) if (name in row) out[name] = row[name]; - return out; - } - return discover ? discoveredFields(row) : {}; -} diff --git a/src/tools/capability-registry/index-loader.ts b/src/tools/capability-registry/index-loader.ts index 33000fe..6bada70 100644 --- a/src/tools/capability-registry/index-loader.ts +++ b/src/tools/capability-registry/index-loader.ts @@ -3,12 +3,7 @@ */ import { readFileSync } from "node:fs"; -import { - Capability, - PagingRule, - RegistryIndex, - SUPPORTED_SCHEMA_VERSION, -} from "./types.js"; +import { Capability, RegistryIndex, SUPPORTED_SCHEMA_VERSION } from "./types.js"; export class IndexError extends Error {} @@ -54,12 +49,6 @@ export class CapabilityRegistry { return this.index.build_id; } - /** Paging controls for one endpoint, or an empty rule when it does not page. */ - pagingFor(product: string, capability: Capability): PagingRule { - const rules = this.index.products[product]?.paging || {}; - return rules[endpointKey(capability.method, capability.path)] || {}; - } - productNames(): string[] { return Object.keys(this.index.products).sort(); } diff --git a/src/tools/capability-registry/register.ts b/src/tools/capability-registry/register.ts index 69be127..23adbaa 100644 --- a/src/tools/capability-registry/register.ts +++ b/src/tools/capability-registry/register.ts @@ -212,8 +212,6 @@ export function addCapabilityRegistryTools( user_permission: z.enum(PERMISSION_VALUES).optional() .describe("Set to 'granted' only after the user has confirmed a write."), change_summary: z.string().optional().describe("What will change. Required for writes."), - order_by: z.string().optional().describe("A returns field; prefix '-' to reverse."), - top_n: z.number().optional().describe("Keep only the first N rows after ordering."), }, async (input): Promise => { track("invokeEndpoint"); @@ -256,8 +254,7 @@ export function addCapabilityRegistryTools( const result = await invoke( capability, args, await deps.baseUrlFor(product), deps.credentialsFor(), transport, - { orderBy: input.order_by, topN: input.top_n }, - registry.pagingFor(product, capability), + ); return ok(result); } catch (error) { diff --git a/src/tools/capability-registry/resolve.ts b/src/tools/capability-registry/resolve.ts index ec1f986..448e670 100644 --- a/src/tools/capability-registry/resolve.ts +++ b/src/tools/capability-registry/resolve.ts @@ -1,31 +1,49 @@ /** - * Invoke one endpoint: page it to completion, find the rows, project them. + * Invoke one endpoint and hand back what the product said. + * + * NO POST-PROCESSING, BY DECISION. There used to be row extraction by shape, a `returns` + * allowlist, a scalars-only filter for undeclared schemas, item counting, ordering, trimming, + * and guards that reported an empty projection as a registration defect. Every one of them + * was a place where we could be wrong ABOUT a correct answer — and each time we were, the + * caller saw a confident empty result rather than an error. The product's response is the + * answer; this module's job is to get it and return it. + * + * ONE REQUEST, ONE RESPONSE. Paging is therefore the caller's, which is why `p` and the + * page-size parameter are published for paginated endpoints (see `project.py::_is_public`). + * Hiding them made sense only while this module walked the pages itself. */ import { bind, GroupedArguments } from "./bind.js"; -import { itemsOf, projectRow, rowFields, totalOf } from "./envelope.js"; import { authHeaders, Credentials, Transport } from "./egress.js"; import { InvocationError } from "./index-loader.js"; -import { Capability, PagingRule } from "./types.js"; - -/** A backstop, not a budget: a runaway pager is a bug, and 60 pages is past any real read. */ -export const MAX_PAGES = 60; +import { Capability } from "./types.js"; export interface InvokeResult { + /** The product answered 2xx. Nothing else decides this. */ ok: boolean; - count: number; - items: Record[]; - complete: boolean; - requests_made: number; - total_reported?: number; - truncated_reason?: "page_cap" | "max_items" | "top_n"; - error?: string; + /** + * Whether this response is the whole answer. + * + * False when the envelope itself says there is another page (`info.next`), so a caller + * knows to ask for one rather than assuming it has everything. This is a peek at one + * declared field, not a reshaping of the body. + */ + completed: boolean; + /** The product's status, and its body exactly as sent. */ + http_response: { + status: number; + body: unknown; + /** Only when there was no response at all to speak for itself. */ + error?: string; + }; } -export interface InvokeOptions { - orderBy?: string; - topN?: number; - pageSize?: number; +function hasNextPage(body: unknown): boolean { + if (typeof body !== "object" || body === null || Array.isArray(body)) return false; + const info = (body as Record).info; + if (typeof info !== "object" || info === null) return false; + const next = (info as Record).next; + return next !== null && next !== undefined && next !== false; } export async function invoke( @@ -34,94 +52,29 @@ export async function invoke( baseUrl: string, credentials: Credentials, transport: Transport, - options: InvokeOptions = {}, - paging: PagingRule = {}, ): Promise { if (!baseUrl) throw new InvocationError("no base URL is configured for that product"); const bound = bind(capability, args); const headers = authHeaders(credentials); - const items: Record[] = []; - const declared = rowFields(capability.returns); - const discover = capability.shape === "discovered"; - let requests = 0; - let rowsSeen = 0; - let total: number | undefined; - let complete = true; - let truncated: InvokeResult["truncated_reason"]; - - for (let page = 1; page <= MAX_PAGES; page += 1) { - const query: Record = { ...bound.query }; - if (paging.page) query[paging.page] = page; - // Ask for the largest page the operation declares. Paging at the product's default was - // the 17 Aug failure: 880 projects walked 30 at a time. - const size = options.pageSize || paging.max; - if (paging.size && size && !(paging.size in query)) query[paging.size] = size; - - const response = await transport( - capability.method, `${baseUrl.replace(/\/$/, "")}${bound.path}`, - headers, query, bound.body, - ); - requests += 1; - - if (response.status === 0 || response.status < 200 || response.status >= 300) { - return { - ok: false, count: items.length, items, complete: false, requests_made: requests, - error: response.error || - `the product answered ${response.status}`, - }; - } - - const rows = itemsOf(response.body); - rowsSeen += rows.length; - total = totalOf(response.body) ?? total; - for (const row of rows) items.push(projectRow(row, declared, discover)); - - if (!paging.page || rows.length === 0) break; - if (total !== undefined && rowsSeen >= total) break; - if (page === MAX_PAGES) { complete = false; truncated = "page_cap"; } - } - - // A DRIFT GUARD, not a retry hint. The product answered with rows and none of them - // carried a single field this capability declares, which is a registration defect — - // trying different arguments will not help. - if (rowsSeen > 0 && items.every((row) => Object.keys(row).length === 0)) { - return { - ok: false, count: 0, items: [], complete: false, requests_made: requests, - error: discover - ? "capability_shape_empty: the product answered with rows, but every field on them " + - "was an object, an array, or a name withheld as sensitive." - : `capability_returns_drift: the product answered with rows, but none of the fields ` + - `this capability declares (${declared.join(", ") || "none"}) were present.`, - }; - } - - let out = items; - if (options.orderBy) { - const field = options.orderBy.replace(/^-/, ""); - const descending = options.orderBy.startsWith("-"); - if (declared.length > 0 && !declared.includes(field)) { - throw new InvocationError( - `order_by must name one of this endpoint's returns fields: ${declared.join(", ")}`, - ); - } - // Rows missing the field sort last in either direction rather than crashing on null. - out = [...items].sort((a, b) => { - const left = a[field], right = b[field]; - if (left === undefined || left === null) return 1; - if (right === undefined || right === null) return -1; - const cmp = left < right ? -1 : left > right ? 1 : 0; - return descending ? -cmp : cmp; - }); - } - if (options.topN && options.topN > 0 && out.length > options.topN) { - out = out.slice(0, options.topN); - truncated = truncated || "top_n"; - } + const response = await transport( + capability.method, + `${baseUrl.replace(/\/$/, "")}${bound.path}`, + headers, + bound.query, + bound.body, + ); + const ok = response.status >= 200 && response.status < 300; return { - ok: true, count: out.length, items: out, complete, requests_made: requests, - ...(total !== undefined ? { total_reported: total } : {}), - ...(truncated ? { truncated_reason: truncated } : {}), + ok, + completed: ok && !hasNextPage(response.body), + http_response: { + status: response.status, + body: response.body, + ...(response.status === 0 + ? { error: response.error || "the product could not be reached" } + : {}), + }, }; } diff --git a/src/tools/capability-registry/types.ts b/src/tools/capability-registry/types.ts index 5cc0731..92e8a2e 100644 --- a/src/tools/capability-registry/types.ts +++ b/src/tools/capability-registry/types.ts @@ -65,11 +65,10 @@ export interface EntityDoc { /** * Paging controls, keyed "METHOD /path". * - * These live BESIDE the capabilities rather than on them, and deliberately. The page and - * size parameter names are the resolver's, not the caller's — publishing them invites a - * caller to drive paging itself, which is what burned the call budget on 17 Aug. Whoever - * RUNS the request still needs them, so they travel here: `resolve` reads this map and the - * search tool never echoes it, leaving the published surface unchanged. + * NOT USED BY THIS SERVER any more: it performs one request and returns the response, so + * paging belongs to the caller and the page parameters are published with the endpoint's + * other query parameters. The field is still emitted by the build, so it stays described + * here rather than silently ignored — a consumer that DOES page can use it. */ export interface PagingRule { page?: string; diff --git a/tests/fixtures/registry-index.json b/tests/fixtures/registry-index.json index 6a8157d..1c30ff6 100644 --- a/tests/fixtures/registry-index.json +++ b/tests/fixtures/registry-index.json @@ -1 +1 @@ -{"schema_version":1,"build_id":"3c872d0b7b-173caps-8bf0e50","harness_commit":"8bf0e508","products":{"tm":{"summary":"BrowserStack Test Management API (v1 \u2014 the SSO/OAuth app API, cookie-authenticated).","capabilities":[{"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"}],"body":[{"name":"owner","type":"integer","description":"The ID of the user to assign as the owner of the test run."}],"intent":"Assign a owner to the test run","guidance":["Assign a user as the owner of a specific test run within a project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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, verified live: 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":"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":["Archive multiple test cases within a project","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"paginated":true},{"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,"description":"Plan INTEGER ids."}],"intent":"Archive test plans in bulk (reversible \u2014 prefer this over delete)","guidance":["REVERSIBLY remove plans from view","Async above the bulk threshold","Soft-archive one or more plans","reach for it whenever the user wants plans \"removed\" or \"cleaned up\" without saying they must be destroyed"],"returns":["async","unique_id"]},{"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"}],"query":[{"name":"include_subfolders_tc","type":"boolean"}],"body":[{"name":"assignee_id","type":"integer","example":2,"description":"ID of the assignee to whom the test cases will be assigned"},{"name":"mapping_ids","type":"array","example":[999,991,1024,1019],"description":"List of test case IDs to be linked"},{"name":"select_all","type":"boolean","example":false,"description":"Flag to select all test cases"},{"name":"de_selected_ids","type":"array","description":"List of test case IDs to be de-selected"},{"name":"folder_id","type":"integer","description":"ID of the folder to which the test cases belong"}],"intent":"Bulk assign test cases to a test run","guidance":["assign specific cases within a run to a user (async above 300)","Assign multiple test cases to a specific test run within a project, allowing for bulk operations on test case assignments"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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` \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, verified live: an unrecognised key here is SILENTLY DROPPED, so a typo widens the ta"},{"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,"json_path":"/test_case/ids"},{"name":"de_selected_ids","type":"array","required":true,"json_path":"/test_case/de_selected_ids"},{"name":"select_all","type":"boolean","required":true,"json_path":"/test_case/select_all"},{"name":"folder_id","type":"string","description":"SOURCE folder that scopes the selection, at top level. OPTIONAL when you pass explicit `ids` (verified live: the copy succeeds without it), but send it whenever `select_all` is true \u2014 it is what bounds the set, and `select_all` with `folder_id` and no `q` copies exactly that folder's cases. Integer and string are both accepted; the declared string matches what the UI sends here, which unlike bulk-"},{"name":"destination_folder_id","type":"integer","required":true,"description":"Target folder for the copies, and the one genuinely required destination field \u2014 omitting it is a bare `400`, verified live. NOTE it is TOP-LEVEL for copy, unlike bulk-move where the destination sits inside `test_case`."},{"name":"destination_project_id","type":"string","description":"Target project id. Needed only for a copy to ANOTHER project. For a copy WITHIN one project it is OPTIONAL \u2014 verified live, omitting it copies correctly into `destination_folder_id`, and integer and string are both accepted. The product does both: its Copy/Move modal sends the source project id, its clone / drag-and-drop path omits it. Send the source id to be explicit or omit it; never invent a v"}],"intent":"Bulk copy test cases","guidance":["copy a selection to a folder (async above 30)","Bulk copy test cases from one folder to another within a project","This operation allows you to copy multiple test cases at once, which can be useful for organizing or duplicating test cases across different folders","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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` \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, verified live: 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":"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 delete.","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":"Delete every case in scope, minus `de_selected_ids`.","json_path":"/test_case/select_all"}],"intent":"Bulk Delete Test Cases from Search","guidance":["All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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."}],"intent":"Delete test results in bulk \u2014 PARTIAL SUCCESS is normal, always read `skipped`","guidance":["PARTIAL SUCCESS is normal, always read the skipped array back","Authorisation is enforced per id (a caller must be the result's author or a project admin)","treating a 200 as \"all deleted\" will silently misreport","result_ids must be a non-empty array (400 otherwise) of at most 300 ids (422 beyond that)","batch larger sets yourself"],"returns":["id","reason"]},{"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` \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, verified live: 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":"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 edit.","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":"automation_status","type":"string","json_path":"/test_case/automation_status"},{"name":"case_type","type":"integer","json_path":"/test_case/case_type"},{"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"},{"name":"owner","type":"integer","description":"TM user id.","json_path":"/test_case/owner"},{"name":"preconditions","type":"string","json_path":"/test_case/preconditions"},{"name":"priority","type":"integer","json_path":"/test_case/priority"},{"name":"status","type":"integer","json_path":"/test_case/status"},{"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"}],"intent":"Bulk Update Test Cases","guidance":["bare values, REPLACE-only (async above 100)","Update multiple test cases within a project","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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"}],"query":[{"name":"include_subfolders_tc","type":"boolean"},{"name":"status_id","type":"integer"}],"body":[{"name":"attachments","type":"array","example":[]},{"name":"de_selected_ids","type":"array","example":[]},{"name":"description","type":"string","example":"

something

","description":"HTML formatted comment or note"},{"name":"folder_id","type":"integer","example":21},{"name":"issues","type":"array","example":[]},{"name":"mapping_ids","type":"array","example":[999,991,1024,1019]},{"name":"select_all","type":"boolean","example":false},{"name":"status_id","type":"integer","example":21},{"name":"test_run_ids","type":"array","example":[1001,1002,1003],"description":"Array of BTCER IDs to apply the bulk edit to (passed when automation = true)"},{"name":"test_case_counts","type":"array","example":[1,3,2],"description":"Array of test case counts corresponding to each BTCER ID in test_run_ids (passed when automation = true)"}],"intent":"Bulk edit test cases result in test run","guidance":["Update multiple test cases in a specific test run within a project, allowing for bulk operations such as changing status, adding attachments, and more"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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, verified live: an unrecognised key here is SILENTLY DROPPED, so a typo widens the ta"},{"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},{"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},{"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`.\n\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},{"name":"value","type":"string"}],"json_path":"/test_case/priority"},{"name":"status","type":"string","fields":[{"name":"operation","type":"string","required":true},{"name":"value","type":"string"}],"json_path":"/test_case/status"},{"name":"case_type","type":"string","fields":[{"name":"operation","type":"string","required":true},{"name":"value","type":"string"}],"json_path":"/test_case/case_type"},{"name":"automation_state","type":"string","fields":[{"name":"operation","type":"string","required":true},{"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},{"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},{"name":"value","type":"string"}],"json_path":"/test_case/owner"},{"name":"preconditions","type":"string","description":"Text value.","fields":[{"name":"operation","type":"string","required":true},{"name":"value","type":"string"}],"json_path":"/test_case/preconditions"},{"name":"review_status","type":"string","fields":[{"name":"operation","type":"string","required":true},{"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},{"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":["PREFERRED bulk write","per-field { value, operation }","Correct for one case too (pass a single id)","Update many test cases in ONE call","Every field is an object of the form {\"value\": \u2026, \"operation\": \u2026}","so this is the only write path that can ADD or REMOVE from a collection without replacing it"],"returns":["async","unique_id"],"paginated":true},{"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` \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, verified live: 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":"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 move.","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":"Move every case in scope, minus `de_selected_ids`.","json_path":"/test_case/select_all"},{"name":"destination_folder_id","type":"integer","description":"Target folder id. Falls back to `folder_id` when omitted.","json_path":"/test_case/destination_folder_id"},{"name":"folder_id","type":"integer","description":"Legacy alias for `destination_folder_id`, used only when that is absent.","json_path":"/test_case/folder_id"},{"name":"destination_project_id","type":"integer","description":"Set only for a cross-project move. Shared cases cannot be moved across projects (422).","json_path":"/test_case/destination_project_id"}],"intent":"Bulk Move Test Cases","guidance":["Move multiple test cases from one folder to another within a project","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"paginated":true},{"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":"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, verified live: 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":"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 Retrieve Test Cases","guidance":["Retrieve multiple test cases within a project based on specified criteria","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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,"description":"Plan INTEGER ids."}],"intent":"Un-archive test plans in bulk (undo bulk-archive)","guidance":["Restore previously archived plans"],"returns":["async","unique_id"]},{"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":"Clone an exploratory session (async when it has many logs).","guidance":["clone a session (async when it has many logs)"]},{"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","description":"Name for the clone. Defaults to a server-generated copy name.","json_path":"/test_plan/name"},{"name":"copy_all_sub_plans","type":"boolean","json_path":"/test_plan/copy_all_sub_plans"},{"name":"sub_plan_ids","type":"array","description":"Clone only these sub-plans. Ignored when copy_all_sub_plans is true.","json_path":"/test_plan/sub_plan_ids"}],"intent":"Clone a test plan, optionally with its sub-plans","guidance":["copy_all_sub_plans or sub_plan_ids to bring its sub-plans along","Copy an existing plan","copy_all_sub_plans: true clones every sub-plan","alternatively pass sub_plan_ids to clone a specific subset","Omit both to clone just the plan itself"]},{"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"}],"body":[{"name":"copy_tc_assignee","type":"boolean","description":"Copy test case assignees from the original run"},{"name":"copy_issues","type":"boolean","description":"Copy issues linked to the original run"},{"name":"copy_tags","type":"boolean","description":"Copy tags from the original run"},{"name":"copy_all_cases","type":"boolean","description":"Include all test cases in the cloned run"},{"name":"status","type":"array","example":["passed","failed"],"description":"Status strings to filter test cases to copy"},{"name":"status_id","type":"array","example":[1,2],"description":"Status IDs to filter test cases to copy"},{"name":"name","type":"string","example":"Cloned Test Run - 2025","description":"Name for the new cloned test run"}],"intent":"Clone a test run","guidance":["Create a new test run by cloning an existing one, with options to copy test case assignees, issues, tags, and filter cases by status"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"Close an exploratory session","guidance":["Transitions an active exploratory session to the closed state"]},{"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"}],"intent":"Close a test run","guidance":["Close a specific test run within a project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"ID of folder to copy"},{"name":"destination_folder_id","type":"integer","required":true,"example":3,"description":"ID of destination parent folder"},{"name":"destination_project_id","type":"integer","required":true,"example":10000,"description":"Destination project ID (can be same or different)"},{"name":"async","type":"boolean","example":true,"description":"Whether to process asynchronously via background job"},{"name":"copy_test_cases","type":"boolean","example":true,"description":"Whether to copy test cases within folder"}],"intent":"Copy a folder to another location","guidance":["Copies a folder (and optionally its contents) to a destination folder within the same or different project","Supports both synchronous and asynchronous operations via the async flag","Async Processing: When async: true, the operation is queued as a Sidekiq background job and returns immediately with a unique tracking ID"],"returns":["async","folder_id","unique_id"]},{"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","example":false,"description":"Select every case in the project.","json_path":"/selection/select_all"},{"name":"unique_test_case_count","type":"integer","example":2,"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.","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`."},{"name":"configurations","type":"array","required":true,"example":[],"description":"Configuration ids. Required \u2014 pass an empty array if there are none."},{"name":"trtc_configuration_mappings","type":"array","description":"Per-configuration case mappings. An ARRAY of objects \u2014 the server rejects an object here.","fields":[{"name":"configuration_id","type":"integer"},{"name":"select_all","type":"boolean"},{"name":"linked_test_cases","type":"array"},{"name":"unlinked_test_cases","type":"array"}]}],"intent":"Count the test cases a selection resolves to (incl. configurations/datasets).","guidance":["count the cases a selection resolves to (incl"],"shape":"discovered"},{"method":"GET","path":"/api/v1/projects/{project_id}/test-plans/{test_plan_id}/test-runs/count","mode":"read","entity":"test_plan","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"test_plan_id","type":"integer","required":true}],"intent":"Count the test runs linked to a test plan","guidance":["how many runs a plan groups","cheaper and safer than paging the run list","Returns just the count of runs linked to the plan","Prefer this over paging the run list when the user only asked \"how many\"","list reads truncate around 30 KB and will make you under-count"],"shape":"discovered"},{"method":"POST","path":"/api/v1/projects/{project_id}/folder/{folder_id}/bulk-test-cases","mode":"write","entity":"folder","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"folder_id","type":"integer","required":true}],"body":[{"name":"test_cases","type":"array","required":true,"fields":[{"name":"name","type":"string","required":true},{"name":"test_case_folder_id","type":"string","required":true},{"name":"attachments","type":"array"},{"name":"automation_state","type":"integer"},{"name":"automation_status","type":"string"},{"name":"case_type","type":"integer"},{"name":"custom_fields","type":"object"},{"name":"description","type":"string"},{"name":"estimate","type":"string"},{"name":"estimated_duration_seconds","type":"integer"},{"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"},{"name":"reviewers","type":"array"},{"name":"send_for_review","type":"boolean"},{"name":"shared_precondition_id","type":"integer"},{"name":"status","type":"integer"},{"name":"tags","type":"array"},{"name":"template","type":"string"},{"name":"template_id","type":"integer"},{"name":"template_step_type","type":"string"},{"name":"test_case_dataset","type":"string"},{"name":"test_case_steps","type":"array"}]},{"name":"unique_id","type":"string","description":"Client-supplied idempotency key. When present and the group has the async bulk-create feature flag enabled, the request is processed asynchronously and acknowledged with 202; completion is delivered over the common WebSocket channel keyed by this id."}],"intent":"Create test cases in bulk for a folder (\u226410) \u2014 UNRELIABLE STATUS, see description.","guidance":["Creates several test cases in one request","more returns 400 \"More than permitted test cases sent\"","A 500 here does NOT mean nothing happened","The action is wrapped in a catch-all rescue that renders 500 {\"success\": false, \"message\": \"Failed to create test cases.\"} for any exception, including ones raised AFTER the cases were already inserted","Same status, same message, opposite outcomes","So on a 500: never retry blind"],"returns":["request_trace_id"]},{"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 new configuration for a project","guidance":["create a configuration ({ name })","Create a new configuration in a specific project"],"returns":["id","name","created_at","group_id","is_suggested","record_status"]},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"body":[{"name":"option_value","type":"string","required":true,"description":"The option label."},{"name":"is_default","type":"boolean","required":true,"description":"REQUIRED \u2014 a missing value is a 400. Must be 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 parent option this hangs under."}],"intent":"Add one option to a dataset \u2014 how you add a dropdown value.","guidance":["ADD one dropdown option","option_value + is_default both required","Adds a single option","Both option_value and is_default are required","a missing is_default is 400 \"Invalid Params\"","For dropdown, at most one option may be the default"]},{"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}],"body":[{"name":"linked_projects","type":"array","description":"Project INTEGER ids this dataset applies to. This is what makes the field visible in a project \u2014 a dataset with no linked projects leaves the field invisible everywhere. NOTE the spelling differs from the project-mappings endpoint, which uses `link_projects` / `unlink_projects`."},{"name":"link_to_future_projects","type":"boolean","description":"Auto-link projects created later."},{"name":"linked_to_all_projects","type":"boolean","description":"Apply to every project in the workspace."},{"name":"options","type":"array","description":"The allowed values, for dropdown-style fields.","fields":[{"name":"option_value","type":"string","required":true},{"name":"is_default","type":"boolean","required":true},{"name":"parent_option_id","type":"integer"}]}],"intent":"Add a dataset (an option set + its project links) to an existing field.","guidance":["add another option set + project scope to an existing field","A dataset is how a field carries options and project scope","A field can hold more than one, letting different projects see different option sets for the same field","the key is linked_projects, and options is accepted at this level (it is a dataset body, not a field body)","for other types a dataset with linked_projects and no options is what scopes the field to projects"]},{"method":"POST","path":"/api/v1/admin-v2/settings/custom-fields","mode":"write","entity":"custom_field","body":[{"name":"field_name","type":"string","required":true},{"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 (e.g. `field_dropdown`) are a different API's and are rejected. Silently immutable after creation: an update that changes it returns 200 and changes nothing."},{"name":"entity_type","type":"string","values":["TestCase","TestResult","TestPlan","ExploratorySession"],"description":"Which entity the field hangs off. One field belongs to exactly one."},{"name":"is_required","type":"boolean","required":true,"description":"REQUIRED, and must be a literal boolean \u2014 omitting it is a 400."},{"name":"datasets","type":"array","required":true,"description":"REQUIRED for every field type, dropdown or not \u2014 omitting it is a 400. Carries the options and the project scope. Send one entry with `linked_projects` set.","fields":[{"name":"linked_projects","type":"array"},{"name":"link_to_future_projects","type":"boolean"},{"name":"linked_to_all_projects","type":"boolean"},{"name":"options","type":"array"}]},{"name":"place_holder_text","type":"string"},{"name":"default_value","type":"string","description":"Only honoured when `field_type` is `boolean`."},{"name":"levels","type":"array","description":"REQUIRED when field_type is `nested_dropdown`, minimum 2 entries, each {name, level_type}."}],"intent":"Create a custom field DEFINITION (workspace-admin action).","guidance":["options AND project scope both go in datasets[]","Creates the field definition","a new attribute available on the chosen entity type","configurable per workspace","so don't predict access","A caller without it gets 403"]},{"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,"description":"Name of the dataset.","json_path":"/dataset/name"},{"name":"variables","type":"array","required":true,"description":"List of dataset variables/columns (max 40).","fields":[{"name":"name","type":"string","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/variables"},{"name":"rows","type":"array","required":true,"description":"List of dataset rows (max 100).","fields":[{"name":"row_number","type":"integer","required":true},{"name":"row_data","type":"array","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/rows"}],"intent":"Create a new dataset.","guidance":["create a dataset with variables + rows","Create a new dataset with variables and rows for a project"],"returns":["name","created_at","rows_count","uuid"]},{"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.","json_path":"/exploratory_session/title"},{"name":"description","type":"string","example":"Explore edge cases in the checkout flow.","description":"Optional description.","json_path":"/exploratory_session/description"},{"name":"charter","type":"string","example":"Focus on payment error handling","description":"Testing charter describing the scope and goals.","json_path":"/exploratory_session/charter"},{"name":"timebox_duration","type":"integer","example":60,"description":"Planned duration in minutes.","json_path":"/exploratory_session/timebox_duration"},{"name":"owner","type":"integer","example":42,"description":"TCM user ID of the session owner / assignee.","json_path":"/exploratory_session/owner"},{"name":"assignee","type":"integer","example":42,"description":"Alias for `owner`. TCM user ID of the assignee.","json_path":"/exploratory_session/assignee"},{"name":"folder_id","type":"integer","example":456,"description":"ID of the folder to place this session in.","json_path":"/exploratory_session/folder_id"},{"name":"tags","type":"array","example":["regression","mobile"],"description":"Tags for the session.","json_path":"/exploratory_session/tags"},{"name":"configurations","type":"array","example":[1,3],"description":"Configuration IDs to associate with this session.","json_path":"/exploratory_session/configurations"},{"name":"attachments","type":"array","example":[101,102],"description":"Blob / media attachment IDs.","json_path":"/exploratory_session/attachments"},{"name":"issues","type":"array","description":"Issues to link to this session.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/exploratory_session/issues"}],"intent":"Create an exploratory session","guidance":["body wrapped in exploratory_session (title required)","Creates a new exploratory session within the specified project"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"]},{"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 button \u2014 spinner appeared but order was not created.

","description":"Rich-text HTML content of the log entry.","json_path":"/session_log/content"},{"name":"status","type":"string","values":["pass","fail","bug","retest","block","note"],"example":"fail","description":"Test result status for this log step.","json_path":"/session_log/status"},{"name":"elapsed_time","type":"integer","example":120,"description":"Time elapsed for this step in seconds.","json_path":"/session_log/elapsed_time"},{"name":"defects","type":"array","description":"Defects to link to this log entry.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/session_log/defects"}],"intent":"Create a session log entry","guidance":["add a log entry (rich-text HTML, sanitised on write)","Adds a new log entry to the specified exploratory session","Rich-text HTML content is sanitised before storage"],"returns":["id","status","content","created_at","elapsed_time","session_id","updated_at"]},{"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,"description":"Name of the filter"},{"name":"entity","type":"string","required":true,"values":["testcase","testplan","testrun","exploratory_session","test_plan_test_case"],"description":"Entity type the filter applies to. The server's validator accepts five values (the\nlast two were missing here); an unlisted value returns 400 \"Invalid JSON data\"."},{"name":"is_project_level","type":"boolean","description":"Whether this filter is available at project level"},{"name":"filters","type":"object","required":true,"description":"Filter criteria object containing the actual filter conditions"}],"intent":"Create a new filter","guidance":["save a filter view ({ name, entity, filters, is_project_level })","Create a new saved filter for test cases, test plans, or test runs in a project"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"]},{"method":"POST","path":"/api/v1/projects","mode":"write","entity":"project","body":[{"name":"name","type":"string","required":true,"json_path":"/project/name"},{"name":"description","type":"string","json_path":"/project/description"}],"intent":"Create a new project.","guidance":["Pinned to human approval","Create a new project with name and description"],"returns":["name","description"]},{"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},{"name":"report_type","type":"string","required":true,"values":["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","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_statu"},{"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 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`"},{"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;\n`custom_report` is a user-configured one. For a Requirement Traceability\nreport, `system_generated` makes the server auto-populate\n`report_filters.requirements` with every requirement in the project."},{"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"},{"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":"Integer run ids \u2014 pairs with report_timeframe=specific_test_runs.","json_path":"/report_filters/test_runs"},{"name":"test_plans","type":"array","description":"Integer plan ids \u2014 pairs with report_timeframe=specific_test_plans.","json_path":"/report_filters/test_plans"},{"name":"test_cases","type":"string","description":"Case selection \u2014 pairs with report_timeframe=specific_test_cases. FOLDER-KEYED\n(see SelectionData), never a flat id list: the server resolves the selection\nthrough its folder map, so any other shape yields zero cases and saves an\nUNSCOPED, project-wide report at 200.\n\nSend all three keys. Folder ids are STRING keys; test-case ids are INTEGERS\n(the numeric `id`, not the TC-NNN identifier) \u2014 take bo","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":"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.","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 } }.","json_path":"/report_filters/test_plan_selection"},{"name":"builds","type":"array","json_path":"/report_filters/builds"},{"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\n`Test Run Summary`, `Test Run Detailed Report` and `Test Case Activity` \u2014 on\nthe other six report types the server never reads it and drops it silently.\nPersisted (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`."},{"name":"users","type":"array","json_path":"/mail_to/users"},{"name":"external_mails","type":"array","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\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"},{"name":"time","type":"string","example":"08:00","description":"24-hour HH:MM, UTC.","json_path":"/frequency_details/time"},{"name":"day","type":"string","example":"monday","description":"Required for weekly (lowercase day name) and monthly (integer 1-28).\nOmit for daily.","json_path":"/frequency_details/day"}],"intent":"Create a new scheduled report for a project","guidance":["Create a report configuration under the given project"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"]},{"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","description":"Name of the root folder"},{"name":"notes","type":"string","example":"This folder contains all regression test cases","description":"Optional notes or description for the folder"}],"intent":"Create a root-level folder for test cases in a project","guidance":["Create a new root-level folder for organizing test cases within a project"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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},{"name":"folder_id","type":"integer","description":"ID of the shared-field folder to place the shared step in. Null or omitted means the root (Unassigned)."},{"name":"shared_step_details","type":"array","required":true,"fields":[{"name":"order","type":"integer","required":true},{"name":"step","type":"string"},{"name":"result","type":"string"},{"name":"test_data","type":"string"}]}],"intent":"Create a new shared step in a project","guidance":["Create a new shared step within a project"],"returns":["id","title","step_count","test_case_count"]},{"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},{"name":"test_run_id","type":"string","required":true},{"name":"test_case_id","type":"integer","required":true},{"name":"step_id","type":"integer","required":true}],"body":[{"name":"status_id","type":"integer","json_path":"/test_run_step_result/status_id"},{"name":"description","type":"string","json_path":"/test_run_step_result/description"}],"intent":"Record a per-step result for a case in a run (v1).","guidance":["record a per-step outcome for a step-templated case ({ status_id, description })"]},{"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,"description":"Name of the new folder.","json_path":"/folder/name"},{"name":"notes","type":"string","description":"Description of the new folder.","json_path":"/folder/notes"}],"intent":"create subfolder inside a specific folder","guidance":["Create a new subfolder within a specific folder in a project"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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 the FIRST test case in a project that has no folders (needs create_at_root).","guidance":["Narrow-purpose route: the first case in a project that has zero folders","Verified live on a folderless project: 200, and the folder appears","The route and the flag always travel together","The product's UI derives both from one boolean","no folders exist AND the user picked no folder","A correct refusal, not something to retry: list the folders and pick one"],"returns":["result","step"]},{"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,"fields":[{"name":"name","type":"string","required":true},{"name":"test_case_folder_id","type":"string","required":true},{"name":"attachments","type":"array"},{"name":"automation_state","type":"integer"},{"name":"automation_status","type":"string"},{"name":"case_type","type":"integer"},{"name":"custom_fields","type":"object"},{"name":"description","type":"string"},{"name":"estimate","type":"string"},{"name":"estimated_duration_seconds","type":"integer"},{"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"},{"name":"reviewers","type":"array"},{"name":"send_for_review","type":"boolean"},{"name":"shared_precondition_id","type":"integer"},{"name":"status","type":"integer"},{"name":"tags","type":"array"},{"name":"template","type":"string"},{"name":"template_id","type":"integer"},{"name":"template_step_type","type":"string"},{"name":"test_case_dataset","type":"string"},{"name":"test_case_steps","type":"array"}]},{"name":"create_at_root","type":"boolean"}],"intent":"Create test cases for a folder in a project.","guidance":["create a case in a folder","body wrapped in test_case, name required","Create one or more test cases within a specific folder in a project","If create_at_root is true, the test case will be created at the root of the project"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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","json_path":"/test_plan/name"},{"name":"description","type":"string","json_path":"/test_plan/description"},{"name":"start_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/start_date"},{"name":"end_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/end_date"},{"name":"plan_status","type":"string","description":"Plan status. The list filter accepts new / started / completed.","json_path":"/test_plan/plan_status"},{"name":"owner","type":"integer","description":"Owner user id \u2014 resolve via the project users read first.","json_path":"/test_plan/owner"},{"name":"parent_plan_id","type":"integer","description":"Parent plan's INTEGER id. Set it to create (or re-parent into) a SUB-plan \u2014 the\nresult carries an STP-NNN identifier. Null/omitted means a top-level plan.","json_path":"/test_plan/parent_plan_id"},{"name":"tags","type":"array","json_path":"/test_plan/tags"},{"name":"reviewers","type":"array","description":"Reviewer user ids.","json_path":"/test_plan/reviewers"},{"name":"attachments","type":"array","description":"Attachment ids already uploaded via the attachments surface.","json_path":"/test_plan/attachments"},{"name":"custom_fields","type":"object","json_path":"/test_plan/custom_fields"},{"name":"issues","type":"array","description":"Linked tracker issues. Structured \u2014 NOT a flat array of keys.","fields":[{"name":"issue_id","type":"string"},{"name":"issue_type","type":"string"},{"name":"metadata","type":"object"}],"json_path":"/test_plan/issues"},{"name":"test_runs","type":"string","description":"The plan's linked test runs. Accepts EITHER an array of test-run integer ids, OR a\nbulk-selection object for \"every run matching this filter\". On update this REPLACES\nthe existing link set rather than appending.","json_path":"/test_plan/test_runs"}],"intent":"Create a test plan \u2014 or a SUB-plan, by setting parent_plan_id","guidance":["body wrapped in test_plan","Create a test plan in the project","Everything goes under the test_plan key","test_runs accepts two shapes","Both are honoured server-side","an unknown option is rejected"]},{"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"},{"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":["log a NEW result for a case in a run","Create a new test result for a specific test case within a test run in a project"],"returns":["id","status","backtrace","configuration_id","created_at","created_by_imported","custom_fields","description","error_description","expanded","failure_type","finished_at"]},{"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],"description":"Explicit case ids to pin into the run. Top level, NOT inside `test_run`. Use this or `selection`."},{"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":["Create a new test run for a specific project, which can be used to execute test cases"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"dataset_id","type":"integer","required":true},{"name":"option_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"Delete one option (POST to /delete) \u2014 DESTRUCTIVE, drops values using it.","guidance":["destroys the values that used it","Removing an option deletes the stored values that used it, across every project linked to the dataset","that preserves the values"]},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"Delete a dataset (POST to /delete) \u2014 DESTRUCTIVE, drops its options and values.","guidance":["drops its options and the values the linked projects stored","Removes the option set and unlinks its projects, destroying the values those projects had stored for this field","Name the affected projects before calling"]},{"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}],"intent":"Delete a field definition (POST to /delete) \u2014 DESTRUCTIVE, cascades to stored values.","guidance":["destroys every stored value in every linked project","Name the projects first","Removes the definition and every value stored for it, in every project it is linked to","There is no bin and no undo","Before calling: read the field, say how many projects it is linked to, and name them","If the user only wants it hidden rather than destroyed, that is a different ask"]},{"method":"DELETE","path":"/api/v1/projects/{project_id}/datasets/{uuid}","mode":"destructive","entity":"dataset","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"uuid","type":"string","required":true}],"intent":"Delete a dataset.","guidance":["say it isn't available rather than calling it, and don't substitute by parsing the file yourself","A 422 here means NOT-ENTITLED, not a bad payload","say so and stop, don't retry with a different body","BINDING shape is DIFFERENT from the dataset shape, and this is the usual failure","the dataset's uuid repeated on every column it owns","The binding object has NO top-level uuid or name (both stripped by strong params)"]},{"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":"Delete an exploratory session","guidance":["Deletes an exploratory session"]},{"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":"Delete a session log entry","guidance":["Permanently deletes a log entry from the specified exploratory session"]},{"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 filter","guidance":["entity = testcase | testplan | testrun | exploratory_session","filter_id is an integer","reuse a saved view's filters as the q for a later search or bulk action"]},{"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":"Delete a folder and its contents","guidance":["Deletes a folder by its ID and optionally returns the parent folder's path hierarchy"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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"}],"intent":"Delete a report (this removes the report itself, not just its schedule)","guidance":["returns the remaining reports","merely stopping a report from recurring","The response is the list of remaining reports","There is no bin and no undo","so confirm the report's name with the user first"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"]},{"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","guidance":["affects every test case embedding it","Deletes a shared step from a project"]},{"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":"Delete an attachment from a test case in a folder in a project","guidance":["read the file from that url"],"returns":["id","byte_size","content_type","filename"]},{"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":"Delete a test plan (or sub-plan) \u2014 no bin, no undo","guidance":["The server performs no plan-type check","so this deletes a sub-plan by its own integer id exactly the same way","Deleting a parent plan is destructive to its children"]},{"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 test result","guidance":["Prefer PATCHing the latest result to correct a mistake","Deleting a result removes an execution record","the case's latest status is recomputed from what remains"]},{"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"}],"intent":"delete test run","guidance":["The discovered ids must be CARRIED INTO the create body","discovery alone puts nothing in the run","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","It is validated before the selection","so a bare 400 'invalid data' means a bad test_run field"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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},{"name":"duplicate_id","type":"integer","required":true}],"body":[{"name":"discard_reason","type":"string","description":"Short reason code / label for why this isn't a duplicate."},{"name":"user_feedback","type":"string","description":"Free-text feedback from the user."}],"intent":"Discard a duplicate suggestion \u2014 dismisses it WITHOUT touching test cases","guidance":["DISCARD a suggestion","dismisses it WITHOUT touching either test case","The safe default when the user says it's not a duplicate or is unsure","Mark a recommendation as discarded","This is the safe way to dismiss a suggestion: it changes only the recommendation's status to discarded and leaves both test cases exactly as they are","discard_reason and user_feedback are optional but feed the dedupe model"]},{"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":"Desired export format. Note this is a STRING here, while the same-named field\non `send_email_now` is an ARRAY."},{"name":"required","type":"object","description":"Optional column selection, honoured only for `Test Run Detailed Report`\n(ignored for every other type). Shape:\n`{ \"\": { \"fields\": [\"id\",\"title\",...] } }`. Omit to get the report's\ndefault columns."}],"intent":"Initiate a download of a report in a specified file format","guidance":["Request generation and download channel for the specified report"],"returns":["channel"]},{"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"},{"name":"custom_fields","type":"object","example":{"123":"option_value","456":["multi","select"]},"json_path":"/test_case/custom_fields"},{"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"},{"name":"preconditions","type":"string","json_path":"/test_case/preconditions"},{"name":"priority","type":"integer","example":483,"json_path":"/test_case/priority"},{"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\".","json_path":"/test_case/reviewers"},{"name":"status","type":"integer","example":496,"json_path":"/test_case/status"},{"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":"string","description":"Dataset binding for a data-driven case. On this path it is applied only when `is_dataset_modified` is true.","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"}],"intent":"Partially edit a test case (v2)","guidance":["PREFERRED partial update","send ONLY changed fields","Partially update the details of a test case within a specific folder in a project","it is the authoritative list","send test_case_steps instead - estimate is accepted and then dropped","estimated_duration_seconds is the one that persists"],"returns":["result","step"]},{"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"},{"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"},{"name":"custom_fields","type":"object","json_path":"/test_case/custom_fields"},{"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"},{"name":"name","type":"string","json_path":"/test_case/name"},{"name":"owner","type":"integer","json_path":"/test_case/owner"},{"name":"preconditions","type":"string","json_path":"/test_case/preconditions"},{"name":"priority","type":"integer","json_path":"/test_case/priority"},{"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"},{"name":"tags","type":"array","json_path":"/test_case/tags"},{"name":"template","type":"string","json_path":"/test_case/template"},{"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":"string","description":"Dataset binding for a data-driven case. On create the mappings are always regenerated from this, so `is_dataset_modified` is ignored here.","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","json_path":"/test_case/test_case_steps"}],"intent":"Edit a test case","guidance":["Update the details of a test case within a specific folder in a project","Omitted fields are left untouched EXCEPT template, template_id and review_status, which are reset to defaults"],"returns":["result","step"]},{"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"}],"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],"description":"Explicit case ids to pin into the run. Top level, NOT inside `test_run`. Use this or `selection`."},{"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":"Edit Test Run","guidance":["Update the details of a specific test run within a project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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","pdf","excel"],"example":"csv","description":"Export file format"},{"name":"start_date","type":"string","example":"2025-01-01","json_path":"/filters/start_date"},{"name":"end_date","type":"string","example":"2025-12-31","json_path":"/filters/end_date"},{"name":"status","type":"array","example":["passed","failed"],"json_path":"/filters/status"}],"intent":"Export dashboard analytics data","guidance":["Initiates an asynchronous export of dashboard analytics data in the specified format","The export is processed via background job and returns an export ID for tracking","Async Processing: Data export runs via Sidekiq ExportDashboardWorker::FileExportWorker","Supported Formats: CSV, PDF, Excel (based on type parameter)"],"returns":["export_id"]},{"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":"Get active test run result statistics","guidance":["Retrieve statistics of active test runs for a specific project"],"shape":"discovered"},{"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":"Get archived test cases.","guidance":["Retrieve a list of archived test cases in a specific project"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"Count archived test plans","guidance":["count archived plans","Returns {success, count}","the number of archived plans in the project"],"shape":"discovered"},{"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 automation stats analytics","guidance":["Retrieve automation statistics for a specific project"],"returns":["automated_coverage","automated_test_cases","empty_data","manual_test_cases","total_test_cases"]},{"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":"Get all the closed test runs for a project","guidance":["Retrieve a list of all closed test runs for a specific project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"Get closed test run analytics","guidance":["Retrieve statistics of closed test runs for a specific project"],"returns":["month"]},{"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":"Get closed test runs split analytics","guidance":["Retrieve split statistics of closed test runs for a specific project"],"returns":["name"]},{"method":"GET","path":"/api/v1/projects/{project_id}/configurations","mode":"read","entity":"configuration","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Get configurations for a project","guidance":["Retrieve a list of configurations for a specific project"],"returns":["id","name","created_at","group_id","is_suggested","record_status"]},{"method":"GET","path":"/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets/{dataset_id}","mode":"read","entity":"custom_field","path_params":[{"name":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"Read one dataset \u2014 its project links and option set.","guidance":["read one dataset's project links and options"],"shape":"discovered"},{"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}],"intent":"Read one custom field definition, with its datasets and project links.","guidance":["read one definition + its datasets and linked projects","do this BEFORE any update, which is a full replace","Options are NOT inline","Datasets come back WITHOUT their options inline","Read this before any update","so you need the current values to avoid clearing them"],"shape":"discovered"},{"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},{"name":"entity_type","type":"string","required":true,"values":["test-case","test-result","test-plan"]},{"name":"field_id","type":"string","required":true}],"query":[{"name":"q","type":"string"}],"intent":"Get the allowed values/options of one custom field.","guidance":["If nothing matches, the field doesn't exist in that workspace","say so rather than guessing a field id","Multi-dropdowns need OPTION IDS, not labels","That legacy family writes a table local to the Rails app that is DEPRECATED and that nothing reads","It is blocked at the gate","testhub owns definitions"],"shape":"discovered","paginated":true},{"method":"GET","path":"/api/v1/projects/{project_id}/datasets/{uuid}","mode":"read","entity":"dataset","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"uuid","type":"string","required":true}],"intent":"Get a specific dataset.","guidance":["read one dataset by UUID","Retrieve a specific dataset by its UUID including all variables and rows"],"returns":["name","created_at","rows_count","uuid"]},{"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},{"name":"uuid","type":"string","required":true}],"intent":"Get test cases linked to a dataset","guidance":["list the test cases bound to this dataset (paged p)","Returns the test cases linked to a dataset, identified by its UUID"],"shape":"discovered","paginated":true},{"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"}],"intent":"Get dataset variables/columns.","guidance":["BINDING shape is DIFFERENT from the dataset shape, and this is the usual failure","the dataset's uuid repeated on every column it owns","The binding object has NO top-level uuid or name (both stripped by strong params)","so do NOT copy the dataset's read shape into it","Never report a dataset as linked from the 200 alone","A dataset is addressed by a UUID (the dataset path param), NOT an integer"],"returns":["name","uuid"],"paginated":true},{"method":"GET","path":"/api/v1/projects/{project_id}/ai-duplicates/status","mode":"read","entity":"duplicate","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Dedupe job status \u2014 use this to tell \"feature off\" from \"no duplicates\"","guidance":["ALWAYS call this when the list is empty","Status of the project's most recent dedupe run"],"returns":["status"]},{"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},{"name":"duplicate_id","type":"integer","required":true}],"intent":"Read the pre-merge snapshot of a MERGED duplicate's source test case","guidance":["after a merge, read the pre-merge snapshot of the case that was folded away","After a merge, the source case no longer exists independently","this returns the snapshot captured at merge time","the only way to show the user what was folded away","Only works for status merged (404 otherwise), and the snapshot may legitimately be absent, which also surfaces as a 404 with error: \"Source test case snapshot not available\"","Treat that as \"not retained\", not as an error to retry"],"shape":"discovered"},{"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},{"name":"duplicate_id","type":"integer","required":true}],"intent":"Read one suggested duplicate, with its test cases resolved","guidance":["read one suggestion with its test cases resolved","do this BEFORE merging so the user can see what would be destroyed","Only status=suggested is readable","Full detail for one recommendation, including the actual test cases it links","so you can show the user what would be merged","Only status suggested is readable here"],"shape":"discovered"},{"method":"GET","path":"/api/v1/projects/{project_id}/{entity}/filter-details","mode":"read","entity":"project","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"entity","type":"string","required":true,"values":["test-cases","trtc","test-runs"],"example":"test-cases"}],"query":[{"name":"q[query]","type":"string","example":"login functionality"},{"name":"q[folder_ids]","type":"string","example":"3306878,3669741,5422801,5422802"},{"name":"q[status]","type":"string","example":"9319,9321"},{"name":"q[priority]","type":"string","example":"550376,9261"},{"name":"q[automation_state]","type":"string","example":"18172471,18172473"},{"name":"q[case_type]","type":"string","example":"9379,9375"},{"name":"q[owner]","type":"string","example":"user123,user456"},{"name":"q[assignee]","type":"string","example":"user789,user101"},{"name":"q[tags]","type":"string","example":"regression,smoke"},{"name":"q[created_at]","type":"string","example":"2024-01-01..2024-12-31"},{"name":"q[updated_at]","type":"string","example":"2024-01-01..2024-12-31"}],"intent":"Get filter details for entity","guidance":["Retrieve filter details for a specific entity type (test-cases, trtc, or test-runs) within a project"],"returns":["id","colour","entity_type","field_name","field_value","internal_name"]},{"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":"Get an exploratory session","guidance":["read one session by INTEGER id","Returns a single exploratory session by its ID"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"]},{"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"]},{"name":"start_date","type":"string"},{"name":"end_date","type":"string"},{"name":"session_ids","type":"string"}],"intent":"Exploratory Session Summary \u2014 live view, no saved report needed","guidance":["exploratory-session summary WITHOUT creating a report","Computes an exploratory-session summary on demand","there is no Schedule row behind it","Use it to answer \"summarise our exploratory sessions\" without creating a report first","Returns session counts, bugs found, top testers (resolved to user objects) and the session list"],"shape":"discovered"},{"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":"Get filter details","guidance":["Retrieve details of a specific saved filter by its ID","It does NOT validate or strip invalid custom-field references","a filter saved with a non-existent custom-field id is returned unchanged","A missing or deleted filter comes back as 400 {\"success\": false, \"message\": \"Filter not found\"}, not 404"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"]},{"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":"Get summary of entities to be deleted when removing a folder","guidance":["PREVIEW what deleting a folder will remove before calling rm","Provides a summary of all entities (test cases, recordings, sub-folders) that would be deleted if the folder is removed"],"returns":["active_test_case_count","archived_test_case_count","sub_folders","test_recordings_count"]},{"method":"GET","path":"/api/v1/projects/{project_id}/repository/mapping","mode":"read","entity":"folder","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Get the project's folder tree (v1).","guidance":["the whole project folder TREE in one call (structure array)"],"shape":"discovered"},{"method":"GET","path":"/api/v1/group/users-v2","mode":"read","entity":"user","query":[{"name":"q","type":"string"}],"intent":"Get group users V2 with pagination","guidance":["Retrieve a paginated list of users in the group with search and filtering capabilities, WITHOUT project scoping","used by the Global Search filter dropdowns","Search by user IDs (comma-separated) or by a name string"],"returns":["id","browserstack_user_id","email","full_name","group_id","onboarded","reports_and_notification_enabled"]},{"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":"Get issues count analytics","guidance":["Retrieve the count of issues for a specific project"],"returns":["label"]},{"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","guidance":["read ONE project by its INTEGER id","so for an integer id this is the right read","Do NOT translate first just to fetch the project","It serves two jobs at once: (1) READ","returns the project's full detail (name, description, permissions, \u2026)","In other words it is NOT translation-only"],"returns":["id","identifier","name","created_at","description","jira_mapped","starred","test_cases_count","test_plans_count","test_runs_count","thProjectId","user_role"]},{"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}],"intent":"Get project-specific custom fields V2 for test cases","guidance":["START HERE for CUSTOM fields","Does NOT expose system-field option ids","Retrieve custom fields, default fields, and system fields for test cases in a specific project (V2 format)"],"returns":["id","default_value","entity_type","field_name","field_type","group_id","is_bulk_editable","is_filterable","is_required","linked_projects_count","place_holder_text","system_name"]},{"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"]}],"intent":"Get project settings","guidance":["Returns an array of enabled setting keys"],"returns":["in_review_count","test_plan_in_review_count"]},{"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"}],"intent":"Get project users V2 with pagination","guidance":["Retrieve paginated list of users in the group with search and filtering capabilities","Search by user IDs (comma-separated) or by name string"],"returns":["id","browserstack_user_id","email","full_name","group_id","onboarded","reports_and_notification_enabled"]},{"method":"GET","path":"/api/v1/projects/basic","mode":"read","entity":"project","query":[{"name":"q[query]","type":"string","example":"nishchay3"},{"name":"q[identifier]","type":"string","example":"PR-60863"}],"intent":"Get paginated projects (lean payload) \u2014 use this to list or count projects","guidance":["LIST or COUNT projects","so you can reach a true total without truncating","Those two are the only recognised keys"],"returns":["id","name","description","import_id","normalisedName","starred","thProjectId"],"paginated":true},{"method":"GET","path":"/api/v1/projects/minify","mode":"read","entity":"project","intent":"Get all projects (minified dropdown) \u2014 has NO name search","guidance":["never use it to resolve a named project or to count them","Minified list of active projects, for dropdown-style selection","It accepts no q in any form","the backend call has no query option","so it can only page through everything","Do NOT use it to resolve a project the user named"],"returns":["id","name","description","import_id","normalisedName","starred","thProjectId"],"paginated":true},{"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},{"name":"attachment_id","type":"integer","required":true}],"intent":"Get download URL for a report attachment","guidance":["Retrieve a secure URL to download a specific report attachment"],"returns":["url"]},{"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"},{"name":"report_data_only","type":"boolean"},{"name":"today","type":"string","example":"2025-05-13"}],"intent":"Retrieve a scheduled report's config and data \u2014 the general report read","guidance":["works for every report type","Full configuration plus computed data for a report of ANY type","request the section and look at the result","which is a real finding","Pass sections to compute the sections you need","several in ONE call, cheaper than one request per section"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"],"paginated":true},{"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"}],"query":[{"name":"widget_type","type":"string"},{"name":"segment","type":"string"}],"intent":"Fetch one report widget/section \u2014 works for EVERY report type.","guidance":["Section names are validated per type","*_drilldown sections return paginated ROWS, not aggregates","Returns a single section's data for any report type","This is the general section read","so when you need SEVERAL sections, prefer that form with a comma-separated sections list and take them in one call rather than one request per section","Most sections return the computed aggregate for a widget"],"shape":"discovered","paginated":true},{"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","requirement_traceability_report"]}],"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":"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`"},{"name":"sections","type":"string","example":"test_run_data,test_run_status"},{"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"]},{"name":"segment","type":"string"}],"intent":"Get summary statistics for a scheduled report \u2014 run and traceability types ONLY","guidance":["400s on every other type","so not for Test Case Activity or Test Plan Summary","Summary statistics for a scheduled report, for THREE report types only","including every other type in ReportType","This is not the general report-read","optionally with sections"],"shape":"discovered","paginated":true},{"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 of the folder to search for"},{"name":"include_folder_hierarchy","type":"boolean","description":"Whether to include folder hierarchy in the response"}],"intent":"Get all folders and subfolders present in the projects.","guidance":["list root folders (paged with p","Returns all folders and subfolders in a project if it exists in TestHub"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"],"paginated":true},{"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":"Get already selected testcases for a tracebility report","guidance":["Retrieve the already selected testcases for a tracebility report"],"returns":["select_all","unique_test_case_count"]},{"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":"paginated","type":"boolean"}],"intent":"Get shared steps","guidance":["read one shared step with its ordered step details","Retrieves a list of shared steps for a project"],"returns":["id","title"],"paginated":true},{"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":"q[query]","type":"string"},{"name":"pre_fetch","type":"boolean"}],"intent":"Get all shared steps for a project","guidance":["Returns a list of all shared steps within a project"],"returns":["id","title","step_count","test_case_count"],"paginated":true},{"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"}],"intent":"THE source for a system field's option ids (priority/status/case_type/automation_state).","guidance":["a single attachment-URL field is enough to push the response past the ~30 KB cap","Supports q to search the option list and p to page it, which matters","a workspace can accumulate dozens of options on a single field"],"shape":"discovered","paginated":true},{"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":"Get all attachments for a test case in a folder in a project","guidance":["list a case's attachments","Retrieve all attachments associated with a specific test case within a folder","CURRENTLY RETURNING 500","Verified live against five different real test cases, with both the case's true folder_id and a mismatched one","a reproducible server error, not a bad request","Do NOT retry it and do not report it to the user as their mistake"],"returns":["id","byte_size","content_type","filename"]},{"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":"Get a test case by INTEGER id (v1, folder-scoped) \u2014 full detail + its TC-NNN identifier","guidance":["READ one case by INTEGER id (folder-scoped)","Read a test case by its INTEGER id","so for an integer id this v1 read is the ONLY way to fetch the case","Two jobs at once: (1) READ","NOT translation-only","Don't fall back to listing the folder to find the case: if you have the integer id, read it here directly"],"returns":["id","identifier","title"]},{"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":"Get test case count trend analytics","guidance":["Retrieve the trend of test case counts for a specific project"],"returns":["field"]},{"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","example":"folder_path"}],"intent":"Get Test Case Details","guidance":["full detail (steps, custom fields, issues) before editing","read this, improve, then write back"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"Get test case histories","guidance":["list all version records for a test case","Retrieve all history records for a specific test case"],"returns":["id","comment","created_at","entity_id","entity_type","group_id","project_id","source","user_id","version_id","version_name"]},{"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}],"intent":"Get test case history diff","guidance":["compare two versions","versions[] with exactly two ids (PAID plan)","Retrieve the diff of a specific history record for a test case"],"returns":["id","order","result","shared_step_id","step"]},{"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":"Get a specific test case history","guidance":["Retrieve details of a specific test case history by its ID"],"returns":["id","comment","created_at","entity_id","entity_type","group_id","project_id","source","user_id","version_id","version_name"]},{"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":"Get tags and metadata for a test case","guidance":["read a case's current tags","Retrieve the tags and metadata associated with a specific test case in a project"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"Get test case type split analytics","guidance":["Retrieve the split of test case types for a specific project"],"returns":["name","field","value","y"]},{"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"}],"query":[{"name":"required[fields]","type":"string","values":["count"]}],"intent":"Get paginated test cases for a test run","guidance":["page the cases in a run (each row is the case you log results against)","Retrieve a paginated list of test cases for a specific test run in a project"],"shape":"discovered"},{"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":"required[fields]","type":"string","example":"owner,priority"},{"name":"required[custom_fields]","type":"string","example":"121,119"},{"name":"all_folders","type":"string","values":["true"],"example":"true"}],"intent":"List a project's test cases \u2014 REQUIRES all_folders=true to be project-wide.","guidance":["Without all_folders=true this returns only ONE folder's cases","the project's first root folder","So a project-wide question answered from a bare call silently under-counts by whatever sits in every other folder, and nothing in the response says so","all_folders is compared as the STRING 'true' (all_folders = params['all_folders'] == 'true')","A JSON boolean true, 1, TRUE or any other value all fail that check and silently fall back to the single-folder scope"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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":"Get a test plan by INTEGER id (v1) \u2014 full detail + its TP-NNN identifier","guidance":["READ one plan by INTEGER id","Read a test plan by its INTEGER id (project integer id in the path)","Don't translate first just to read the plan","this returns its full detail directly","Two jobs at once: (1) READ","the plan's full detail (under test_plan"],"returns":["identifier","name"]},{"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"},{"name":"test_run_id","type":"integer"},{"name":"today","type":"string"}],"intent":"Execution-trend chart data for ONE plan or ONE run (XOR)","guidance":["execution-trend chart data for ONE plan or ONE run","pass test_plan_id XOR test_run_id (both or neither is a 400)","Time-series execution trend backing the Insights tab chart","Scope it with exactly one of test_plan_id or test_run_id","Passing both returns 400 \"Provide either test_plan_id or test_run_id, not both\"","passing neither returns 400 \"test_plan_id or test_run_id is required\""],"shape":"discovered"},{"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 a test plan","guidance":["chart data for the exploratory sessions linked to a plan","Aggregated chart data for exploratory sessions linked to the plan","the other half of the Insights tab","Like execution-trend, this is chart data only","insights widgets themselves are not a CRUD resource here"],"shape":"discovered"},{"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":"include_sub_test_plan_runs","type":"boolean"},{"name":"compact","type":"boolean"},{"name":"is_archived","type":"boolean"}],"intent":"List the test runs linked to a test plan","guidance":["list the runs a plan groups","Paginated list of the runs a plan groups","that field replaces the link set rather than appending to it","include_sub_test_plan_runs=true also returns runs belonging to the plan's sub-plans"],"shape":"discovered","paginated":true},{"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}],"intent":"Get paginated custom fields for test results","guidance":["Retrieve paginated list of custom fields configured for test results in a project"],"returns":["id","default_value","entity_type","field_name","field_type","field_user_name","is_bulk_editable","is_filterable","is_required","optional","placeholder"],"paginated":true},{"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"},{"name":"test_case_id","type":"integer","required":true}],"intent":"Get test results for a test case in a test run","guidance":["read a case's results within a run","Retrieve a list of test results for a specific test case within a test run in a project"],"returns":["id","status","backtrace","configuration_id","created_at","created_by_imported","custom_fields","description","error_description","expanded","failure_type","finished_at"]},{"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":["READ a run by INTEGER id","Read a test run by its INTEGER id (with the project's integer id in the path)","Don't translate first just to read the run","this returns its full detail directly","Two jobs at once: (1) READ","NOT translation-only"],"returns":["id","identifier","name","uuid"]},{"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},{"name":"test_run_id","type":"string","required":true}],"intent":"Get a run with its per-case rows (v1).","guidance":["the run with its per-case rows (did it pass?)"],"shape":"discovered"},{"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"}],"intent":"Get custom field values for test results in test runs","guidance":["Retrieve default field values and optionally custom fields for test results (TRTC - Test Run Test Case)"],"returns":["id","applies_to_all_projects","default_value","entity_type","field_name","field_type","is_required","link_to_future_projects","place_holder_text"]},{"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.","json_path":"/selection/select_all"},{"name":"unique_test_case_count","type":"integer","example":2,"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.","json_path":"/selection/folders"},{"name":"shared_selection","type":"object"}],"intent":"get selected test runs of a project","guidance":["Retrieve selected test runs for a specific project based on the provided selection criteria"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"ignore_run_type","type":"boolean"},{"name":"include_test_plan","type":"boolean"}],"intent":"Get all the test runs for a project","guidance":["list the project's (open) runs, paged with p","Retrieve a list of all test runs for a specific project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"Import a CSV dataset \u2014 NOT SUPPORTED in this profile","guidance":["NOT SUPPORTED in this profile","say CSV import isn't available","CSV import is not supported here","If asked to import a dataset from CSV, say it isn't available and point to the Test Management UI"]},{"method":"GET","path":"/api/v1/activities","mode":"read","entity":"activity","query":[{"name":"project_id","type":"integer"},{"name":"cursor","type":"string"},{"name":"limit","type":"integer"},{"name":"actor_id","type":"integer"},{"name":"starred_only","type":"boolean"},{"name":"entity_type","type":"array"}],"intent":"Read the activity feed (audit trail) \u2014 CURSOR paged, 15-day window","guidance":["WHO changed WHAT recently","group-wide audit trail","Group-wide audit trail of who changed what","Read-only: activity events cannot be created, edited, or deleted","Two things differ from every other list in this profile: - Cursor pagination, not page numbers","so you cannot jump to a page or read a total"],"returns":["no_starred_projects"]},{"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}],"intent":"List archived test plans","guidance":["where a 'missing' plan usually is, and the source of ids for bulk-retrieve","Paginated list of the project's archived plans","so if a plan the user names seems missing, look here before concluding it was deleted"],"shape":"discovered","paginated":true},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"List a dataset's options with their ids.","guidance":["list a dataset's options WITH their ids","the ids a filter or a test-case write needs"],"shape":"discovered"},{"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"},{"name":"q[uuid]","type":"string"}],"intent":"List datasets for a project.","guidance":["list datasets (paged p","Retrieve a paginated list of datasets for a project with optional filtering by name or UUID"],"returns":["name","created_at","rows_count","test_cases_count","updated_at","uuid"],"paginated":true},{"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":"entity_type","type":"string"},{"name":"entity_id","type":"integer"},{"name":"duplicates","type":"string"}],"intent":"List AI-suggested duplicate test cases \u2014 an empty list may mean the feature is OFF","guidance":["LIST the duplicate suggestions awaiting review","An EMPTY list may mean the feature is off","List the AI dedupe recommendations awaiting review in a project","Only duplicates with status suggested are returned","once merged, archived, or discarded they leave this list","An empty result is ambiguous"],"shape":"discovered","paginated":true},{"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}],"intent":"List defects for an exploratory session","guidance":["List defaults to active sessions","the parent project takes the INTEGER project id (v1)","defects are the issues linked to the session"],"returns":["issue_id","issue_type"],"paginated":true},{"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}],"intent":"List logs for an exploratory session","guidance":["page the session's log entries","Returns a paginated list of log entries for the specified exploratory session"],"returns":["id","status","content","created_at","elapsed_time","session_id","updated_at"],"paginated":true},{"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":"q[session_state]","type":"string","values":["active","closed"],"example":"active"},{"name":"q[assignee]","type":"integer","example":42},{"name":"q[owner]","type":"integer","example":42},{"name":"q[created_by]","type":"integer","example":10},{"name":"q[created_at_from]","type":"string","example":"2026-01-01"},{"name":"q[created_at_to]","type":"string","example":"2026-01-31"},{"name":"q[tags][]","type":"array","example":["regression","payment"]},{"name":"q[configuration_ids][]","type":"array","example":[1,2]},{"name":"q[search]","type":"string","example":"checkout"},{"name":"q[status]","type":"string","example":"pass"}],"intent":"List exploratory sessions for a project","guidance":["list sessions (defaults to active","Returns a paginated list of exploratory sessions"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"],"paginated":true},{"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","type":"string","required":true,"values":["testcase","testplan","testrun","exploratory_session","test_plan_test_case"]}],"intent":"List filters","guidance":["list saved filter views (optional entity = testcase|testplan|testrun|exploratory_session)","Retrieve a paginated list of saved filters for a project, optionally filtered by entity type"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"],"paginated":true},{"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":"List subfolders and folder metadata for a specific folder in a project","guidance":["one folder's detail + its direct subfolders + ancestors","Retrieve a list of subfolders and their metadata for a specific folder in a project"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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":"required[fields]","type":"string","example":"owner,priority"},{"name":"required[custom_fields]","type":"string","example":"121,119"}],"intent":"List the test cases in one folder","guidance":["Page through the cases directly inside a folder, by the folder's INTEGER id","Use this when the user has already named or selected a folder","Rows are verbose and list reads truncate around 30 KB"],"shape":"discovered","paginated":true},{"method":"GET","path":"/api/v1/projects","mode":"read","entity":"project","query":[{"name":"q","type":"string","example":"nishchay3"},{"name":"sort_by","type":"string"},{"name":"starred","type":"boolean"},{"name":"shared","type":"boolean"},{"name":"is_hidden_projects","type":"boolean","example":true}],"intent":"List projects for a group.","guidance":["Paginated list of the group's projects","query and page are not read by the server","so use this to FIND a project, not to enumerate them"],"returns":["id","identifier","name","created_at","description","import_id","project_creator","test_cases_count","test_runs_count"],"paginated":true},{"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"},{"name":"q[scheduled]","type":"string","values":["true","false"]}],"intent":"List all the scheduled reports","guidance":["list the project's report schedules (paged p","Retrieve a paginated list of schedules"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"],"paginated":true},{"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":"q","type":"string"}],"intent":"List test-case tags (paginated).","guidance":["list a project's test-case tags (paged with p, optional q)"],"shape":"discovered","paginated":true},{"method":"GET","path":"/api/v1/admin-v2/settings/templates","mode":"read","entity":"","query":[{"name":"entity_type","type":"string","values":["TestCase","TestResult","TestPlan","ExploratorySession"]},{"name":"project","type":"integer"},{"name":"name","type":"string"}],"intent":"List the workspace's test-case templates \u2014 THE source for `template_id`.","guidance":["Ids are per-workspace","so one carried over from another workspace (or from an example) produces the generic 400 \"The API request is invalid or improperly formed\" on a test-case create, with nothing naming the offending field","Call this when the user asked for a named template, or to confirm a template id before reusing one","One call therefore yields both fields","NOTE THE CASING: entity_type here is CamelCase (TestCase), unlike the hyphenated test-case used in path segments elsewhere","Passing test-case returns an empty list with a 200"],"shape":"discovered","paginated":true},{"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":"include_sub_plans","type":"boolean"},{"name":"status","type":"string","values":["new","started","completed"]},{"name":"sort_by","type":"string"},{"name":"minify","type":"boolean"}],"intent":"List test plans in a project (optionally including sub-plans)","guidance":["include_sub_plans=true to see sub-plans too","Paginated list of the project's test plans","This is how you find a plan's integer id before reading, updating, or deleting it","so never try to find a plan through search","Without it you see only top-level plans and a plan's children are invisible"],"shape":"discovered","paginated":true},{"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"]},{"name":"field_source","type":"string","values":["system","custom","all"]},{"name":"field_type","type":"string"},{"name":"q","type":"string"}],"intent":"THE canonical workspace field list (system + custom) \u2014 start here for definitions.","guidance":["workspace-wide list of DEFINITIONS across all projects","'does a field called X exist anywhere?'","The authoritative list (testhub-backed)","This is the authoritative definition list: it is backed by testhub, which owns custom-field definitions","That legacy collection reads a DEPRECATED table local to the Rails app that nothing else consults","its contents are unrelated to the real fields"],"shape":"discovered","paginated":true},{"method":"POST","path":"/api/v1/projects/{project_id}/tags/merge","mode":"write","entity":"tag","path_params":[{"name":"project_id","type":"integer","required":true}],"body":[{"name":"source_id","type":"integer","required":true},{"name":"target_id","type":"integer","required":true},{"name":"entity_type","type":"string","required":true,"values":["test_case","test_run","test_plan","shared_step"]}],"intent":"Merge one tag into another (v1). Admin-gated (tags_management).","guidance":["merge one tag into another ({ source_id, target_id, entity_type })"]},{"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","required":true,"example":123,"description":"ID of the new parent folder (internal APIs)"},{"name":"new_base_project_id","type":"integer","example":456,"description":"Optional destination project ID for cross-project moves"},{"name":"user_action","type":"string","example":"move_folder"}],"intent":"Move a test case folder to a different parent folder","guidance":["move a folder ({ new_base_folder_id, new_base_project_id })","cross-project moves run async","Move a test case folder to a new parent folder within the same project","This operation updates the folder's hierarchy without changing its contents"],"returns":["async","unique_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","required":true,"description":"Name of the new folder.","json_path":"/folder/name"},{"name":"notes","type":"string","description":"Description of the new folder.","json_path":"/folder/notes"}],"intent":"rename a folder in a project","guidance":["Rename an existing folder within a specific project"],"returns":["request_trace_id"]},{"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],"description":"List of folder IDs to reorder"},{"name":"prev","type":"integer","example":101,"description":"ID of the folder that will precede the moved folder"},{"name":"next","type":"integer","example":103,"description":"ID of the folder that will follow the moved folder"}],"intent":"Reorder folders in a project","guidance":["Reorder folders within a project by specifying the current order and optionally the previous and next folder IDs"],"returns":["request_trace_id"]},{"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":"ID of the test case that will be above the moved ones.","json_path":"/re_order/top_test_case"},{"name":"bottom_test_case","type":"string","description":"ID of the test case that will be below the moved ones.","json_path":"/re_order/bottom_test_case"},{"name":"test_cases","type":"array","required":true,"description":"Array of test case IDs to be moved.","json_path":"/re_order/test_cases"}],"intent":"Reorder test cases within a folder of a project","guidance":["reorder cases within a folder","Reorders test cases in a folder by placing them between top_test_case and bottom_test_case"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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. Passed in the BODY, not the path."},{"name":"merge_action","type":"string","required":true,"description":"`merge` folds source into target; any other value archives."},{"name":"source_testcase_id","type":"integer","description":"Required for merge \u2014 the case that will cease to exist independently."},{"name":"target_testcase_id","type":"integer","description":"Required for merge \u2014 the case that survives."}],"intent":"Resolve a duplicate by MERGING or ARCHIVING \u2014 destroys or hides a test case","guidance":["RESOLVE a suggestion","merge_action=merge folds source_testcase_id into target_testcase_id (DESTRUCTIVE to a test case), anything else archives","duplicate_id goes in the BODY","Act on one dedupe recommendation","merge_action selects what happens: - \"merge\"","folds source_testcase_id into target_testcase_id"]},{"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":"Restore a specific history entry","guidance":["roll the case back to this version (PAID plan","Restore a specific history entry by its ID"]},{"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":"duplicates","type":"string","description":"Duplicate-type filter."},{"name":"owner","type":"string","description":"Owner tab \u2014 scopes to the caller's own duplicates vs all."}],"intent":"Filtered search over suggested duplicates","guidance":["filtered search over suggestions (owner tab, duplicate type, test-case attributes)","Subject to the identical flag caveat: an empty list may mean the feature is off rather than that there are no duplicates"],"shape":"discovered","paginated":true},{"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"}],"query":[{"name":"q","type":"string"}],"intent":"Search group tags for an entity type","guidance":["search tags across the whole group (entity_type = test-case|test-run|test-plan)","Retrieve a paginated list of tags for the given entity type across the group (no project scoping)","used by the Global Search filter dropdowns"],"returns":["name","created_at","usage_count"],"paginated":true},{"method":"GET","path":"/api/v1/projects/{project_id}/{entity}/search","mode":"read","entity":"project","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"entity","type":"string","required":true,"values":["test-cases","test-runs","test-plans","reports"]}],"query":[{"name":"q[query]","type":"string","example":"tc"},{"name":"q[folder_id]","type":"integer","example":29529465},{"name":"use_bstack_id","type":"string","values":["0","1"]},{"name":"include_test_plan","type":"boolean"}],"intent":"Search for entities within a project","guidance":["Returns paginated results matching the search criteria"],"shape":"discovered","paginated":true},{"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":"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":["Note: Array values in the q parameter are automatically converted to comma-separated strings for compatibility with existing search logic"],"shape":"discovered","paginated":true},{"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"}],"intent":"Search users with access to a project","guidance":["Retrieves a paginated list of users who have access to the specified project","Returns user details including permissions, feature flags, and product roles"],"returns":["id","browserstack_user_id","customisable_dashboard_accessible","full_name","group_id","has_access","is_build_listing_enabled","is_delighted_visible","is_jira_app_project_panel_visible","is_lcnc_explore_dismissed","is_new_project_settings_visible","is_onboarding_project_header_visible"],"paginated":true},{"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"}],"intent":"Search test case tags","guidance":["Retrieves a paginated list of tags associated with test cases in the project","Returns both simple tag strings and detailed tag objects with metadata"],"returns":["name","created_at","usage_count"],"paginated":true},{"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":"query","type":"string"},{"name":"use_bstack_id","type":"string","values":["0","1"]}],"intent":"Search test cases in a project","guidance":["and see pagination-and-filtering for the key table and each value's source","there is NO top-level values array, and looking for one finds nothing","so it is routinely cut off and appears absent","NEVER probe candidate integers for an id","The four system option fields","a name silently returns 0 because the server matches an id column"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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","example":["qa-leads@company.com"],"description":"Recipient addresses. MUST be an array, even for one address."},{"name":"file_type","type":"array","example":["pdf"],"description":"Formats to attach. MUST be an array. Defaults to [\"pdf\"]."}],"intent":"Email a report immediately (async job).","guidance":["email the report immediately (async job)","Kicks off a background send","a 200 means the job was queued, NOT that mail was delivered","don't report it as sent","Both body fields are ARRAYS and are rejected with 400 \" should be an array\" if sent as scalars","Omitting emails sends to the report's configured recipients"]},{"method":"POST","path":"/api/v1/projects/{project_id}/folder/{folder_id}/undo-rm","mode":"write","entity":"folder","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"folder_id","type":"integer","required":true}],"intent":"Undo folder deletion","guidance":["Restores a previously deleted folder and returns its metadata along with path hierarchy"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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":"dataset_id","type":"integer","required":true},{"name":"option_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"body":[{"name":"option_value","type":"string","required":true,"description":"The option label."},{"name":"is_default","type":"boolean","required":true,"description":"REQUIRED \u2014 a missing value is a 400. Must be 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 parent option this hangs under."}],"intent":"Rename an option or change its default (POST to /update).","guidance":["NON-destructive, stored values follow the option id","Both option_value and is_default are required","a missing is_default is a 400","Renaming an option keeps the stored values pointing at it (the option id is unchanged)","so this is the NON-destructive way to fix a label"]},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"body":[{"name":"link_projects","type":"array","description":"Project INTEGER ids to ADD. Note the spelling \u2014 not `linked_projects`."},{"name":"unlink_projects","type":"array","description":"Project INTEGER ids to REMOVE. Destroys their stored values."},{"name":"link_to_future_projects","type":"boolean"},{"name":"select_all","type":"boolean","description":"Apply to every project; may switch the call to async."}],"intent":"Link or unlink projects for a dataset \u2014 how you change which projects see a field.","guidance":["CHANGE WHICH PROJECTS","Unlinking destroys that project's values","The keys here are link_projects and unlink_projects","Send the wrong spelling and it is silently dropped: 200, nothing changed","This is the single easiest mistake on this surface","These are DELTAS, not the complete new list: send only the projects to add and the projects to remove"]},{"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,"description":"Display label. Editable. REQUIRED even when unchanged."},{"name":"field_type","type":"string","required":true,"description":"REQUIRED for validation, but any change is silently ignored."},{"name":"is_required","type":"boolean","required":true,"description":"REQUIRED \u2014 omitting it is a 400."},{"name":"entity_type","type":"string"},{"name":"place_holder_text","type":"string"},{"name":"default_value","type":"string","description":"Only applied when `field_type` is `boolean`."}],"intent":"Update a field definition's label, requiredness or placeholder (POST to /update).","guidance":["Full replace: field_name + field_type + is_required all required","field_type changes are silently ignored","field_name, field_type and is_required must all be present","Omitting is_required is 400 \"Invalid Params\"","omitting field_name is a 500","field_name IS editable here"]},{"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","description":"Updated name of the dataset.","json_path":"/dataset/name"},{"name":"variables","type":"array","description":"Updated list of dataset variables/columns (max 40).","fields":[{"name":"name","type":"string","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/variables"},{"name":"rows","type":"array","description":"Updated list of dataset rows (max 100).","fields":[{"name":"row_number","type":"integer","required":true},{"name":"row_data","type":"array","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/rows"}],"intent":"Update a dataset.","guidance":["Update an existing dataset by its UUID with new variables and rows"],"returns":["name","created_at","rows_count","uuid"]},{"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 for the session.","json_path":"/exploratory_session/title"},{"name":"description","type":"string","description":"Updated description.","json_path":"/exploratory_session/description"},{"name":"charter","type":"string","description":"Updated testing charter.","json_path":"/exploratory_session/charter"},{"name":"timebox_duration","type":"integer","example":90,"description":"Updated planned duration in minutes.","json_path":"/exploratory_session/timebox_duration"},{"name":"duration","type":"integer","example":45,"description":"Tracked duration in minutes.","json_path":"/exploratory_session/duration"},{"name":"actual_duration","type":"integer","example":50,"description":"Final actual duration in minutes.","json_path":"/exploratory_session/actual_duration"},{"name":"folder_id","type":"integer","example":789,"description":"Move the session to a different folder.","json_path":"/exploratory_session/folder_id"},{"name":"owner","type":"integer","example":55,"description":"TCM user ID of the new owner / assignee.","json_path":"/exploratory_session/owner"},{"name":"assignee","type":"integer","example":55,"description":"Alias for `owner`.","json_path":"/exploratory_session/assignee"},{"name":"tags","type":"array","example":["smoke","payment"],"description":"Replace the session's tags with this list.","json_path":"/exploratory_session/tags"},{"name":"configurations","type":"array","example":[2,4],"description":"Replace the session's configuration IDs.","json_path":"/exploratory_session/configurations"},{"name":"attachments","type":"array","description":"Updated set of blob / media attachment IDs.","json_path":"/exploratory_session/attachments"},{"name":"issues","type":"array","description":"Replace the linked issues for this session.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/exploratory_session/issues"}],"intent":"Update an exploratory session","guidance":["edit a session (title, charter, timebox_duration, duration, owner, tags, ...)","Updates one or more fields of an exploratory session","Time fields (timebox_duration, duration, actual_duration) must be non-negative integers not exceeding 2147483647"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"]},{"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":"Updated rich-text HTML content of the log entry.","json_path":"/session_log/content"},{"name":"status","type":"string","values":["pass","fail","bug","retest","block","note"],"example":"fail","description":"Updated test result status.","json_path":"/session_log/status"},{"name":"elapsed_time","type":"integer","example":180,"description":"Updated elapsed time in seconds.","json_path":"/session_log/elapsed_time"},{"name":"defects","type":"array","description":"Replace the linked defects for this log entry.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/session_log/defects"}],"intent":"Update a session log entry","guidance":["Updates an existing log entry within an exploratory session","Rich-text HTML content is sanitised before storage"],"returns":["id","status","content","created_at","elapsed_time","session_id","updated_at"]},{"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,"description":"Name of the filter"},{"name":"entity","type":"string","required":true,"values":["testcase","testplan","testrun","exploratory_session","test_plan_test_case"],"description":"Entity type the filter applies to. The server's validator accepts five values (the\nlast two were missing here); an unlisted value returns 400 \"Invalid JSON data\"."},{"name":"is_project_level","type":"boolean","description":"Whether this filter is available at project level"},{"name":"filters","type":"object","description":"Filter criteria object containing the actual filter conditions"}],"intent":"Update a filter","guidance":["Update an existing saved filter with new configuration or filter criteria"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"]},{"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"},{"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 these are ids, not names).","json_path":"/test_result/status_id"},{"name":"description","type":"string","description":"Rich text description of the test result.","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's 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":"Update the latest test result","guidance":["update the LATEST result for a case in a run (partial, same body shape)","Update the most recent test result for a specific test case within a test run in a project","Optionally filter by configuration_id or mapping_id"],"returns":["id","name","byte_size","checksum","content_type","download_url","key","product_id","size","url"]},{"method":"POST","path":"/api/v1/projects/{project_id}/settings","mode":"write","entity":"project","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Update project settings","guidance":["Accepts an object with setting keys as properties and boolean values"]},{"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","example":10000},{"name":"report_type","type":"string","required":true,"values":["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","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_statu"},{"name":"title","type":"string","example":"Updated Title"},{"name":"description","type":"string","example":""},{"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 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`"},{"name":"report_creation_mode","type":"string","required":true,"values":["custom_report","system_generated"],"example":"custom_report"},{"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"},{"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":"Integer run ids \u2014 pairs with report_timeframe=specific_test_runs.","json_path":"/report_filters/test_runs"},{"name":"test_plans","type":"array","description":"Integer plan ids \u2014 pairs with report_timeframe=specific_test_plans.","json_path":"/report_filters/test_plans"},{"name":"test_cases","type":"string","description":"Case selection \u2014 pairs with report_timeframe=specific_test_cases. FOLDER-KEYED\n(see SelectionData), never a flat id list: the server resolves the selection\nthrough its folder map, so any other shape yields zero cases and saves an\nUNSCOPED, project-wide report at 200.\n\nSend all three keys. Folder ids are STRING keys; test-case ids are INTEGERS\n(the numeric `id`, not the TC-NNN identifier) \u2014 take bo","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":"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.","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 } }.","json_path":"/report_filters/test_plan_selection"},{"name":"builds","type":"array","json_path":"/report_filters/builds"},{"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\n`Test Run Summary`, `Test Run Detailed Report` and `Test Case Activity` \u2014 on\nthe other six report types the server never reads it and drops it silently.\nPersisted (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`."},{"name":"users","type":"array","json_path":"/mail_to/users"},{"name":"external_mails","type":"array","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\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"},{"name":"time","type":"string","example":"08:00","description":"24-hour HH:MM, UTC.","json_path":"/frequency_details/time"},{"name":"day","type":"string","example":"monday","description":"Required for weekly (lowercase day name) and monthly (integer 1-28).\nOmit for daily.","json_path":"/frequency_details/day"}],"intent":"Update a scheduled report","guidance":["FULL REPLACE, not a delta: omitted fields are nulled and dropping frequency cancels the schedule","Update the configuration of an existing scheduled report for a project"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"]},{"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":"Title of the shared step"},{"name":"folder_id","type":"integer","description":"ID of the shared-field folder to move the shared step into. Null moves it to the root (Unassigned)."},{"name":"shared_step_details","type":"array","required":true,"fields":[{"name":"id","type":"integer"},{"name":"step","type":"string"},{"name":"result","type":"string"},{"name":"order","type":"integer"},{"name":"test_data","type":"string"}]}],"intent":"Update a shared step","guidance":["update a shared step","title and shared_step_details are BOTH required, and the details array REPLACES the existing steps","Updates an existing shared step in a project"],"returns":["id","title"]},{"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"},{"name":"description","type":"string","json_path":"/test_plan/description"},{"name":"start_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/start_date"},{"name":"end_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/end_date"},{"name":"plan_status","type":"string","description":"Plan status. The list filter accepts new / started / completed.","json_path":"/test_plan/plan_status"},{"name":"owner","type":"integer","description":"Owner user id \u2014 resolve via the project users read first.","json_path":"/test_plan/owner"},{"name":"parent_plan_id","type":"integer","description":"Parent plan's INTEGER id. Set it to create (or re-parent into) a SUB-plan \u2014 the\nresult carries an STP-NNN identifier. Null/omitted means a top-level plan.","json_path":"/test_plan/parent_plan_id"},{"name":"tags","type":"array","json_path":"/test_plan/tags"},{"name":"reviewers","type":"array","description":"Reviewer user ids.","json_path":"/test_plan/reviewers"},{"name":"attachments","type":"array","description":"Attachment ids already uploaded via the attachments surface.","json_path":"/test_plan/attachments"},{"name":"custom_fields","type":"object","json_path":"/test_plan/custom_fields"},{"name":"issues","type":"array","description":"Linked tracker issues. Structured \u2014 NOT a flat array of keys.","fields":[{"name":"issue_id","type":"string"},{"name":"issue_type","type":"string"},{"name":"metadata","type":"object"}],"json_path":"/test_plan/issues"},{"name":"test_runs","type":"string","description":"The plan's linked test runs. Accepts EITHER an array of test-run integer ids, OR a\nbulk-selection object for \"every run matching this filter\". On update this REPLACES\nthe existing link set rather than appending.","json_path":"/test_plan/test_runs"}],"intent":"Update a test plan (or sub-plan)","guidance":["Update a plan by its INTEGER id","Takes the same body as create","so it also works on a sub-plan by its own id","clearing it promotes a sub-plan to a top-level plan","do that only when the user actually asked to move it in the hierarchy","Send only the fields you intend to change"]},{"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 AI-generated or AI-processed attachments","guidance":["upload a file as AI CONTEXT (restricted MIME types, smaller size cap) to seed test-case content","Files are stored in S3 with pre-signed URLs","File Storage: Files uploaded to S3 with pre-signed URLs (expires in ~26 minutes)"],"returns":["id","name","content_type","download_url","size","url"]},{"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 generic attachments to a project from rich text editor or other sources","guidance":["UPLOAD file blob(s) (multipart)","Uploads one or more files as generic attachments to a project","Files are stored in S3 and pre-signed URLs are returned for both viewing and downloading"],"returns":["id","name","content_type","download_url","size","url"]},{"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":"Upload one or more attachments to a test case","guidance":["Upload one or more attachments to a specific test case within a folder in a project"],"returns":["id","byte_size","content_type","filename"]},{"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 whose per-test-case / per-run / per-day breakdown is requested."}],"intent":"Fetch the User Workload drill-down for a single user","guidance":["User-Workload per-user execution breakdown","Only valid on a report whose type is User Workload Report","every other type returns 400 \"Drill-down is only available for User Workload Reports\""],"shape":"discovered","paginated":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},"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)","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_v1"]},"attachment":{"entity":"attachment","title":"Attachments","aliases":["attachment","file","attachments","blob"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"attached to"},{"entity":"result","via":"attached to"}],"capabilities":["delete_test_case_attachment","get_test_case_attachments_v1","upload_a_i_attachments","upload_generic_attachments","upload_test_case_attachments_v1"]},"configuration":{"entity":"configuration","title":"Configurations","aliases":["config","configuration","environment","browser","os","device"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_run","via":"case status tracked against"}],"capabilities":["create_configuration_v1","get_configurations_v1"]},"custom_field":{"entity":"custom_field","title":"Custom fields & system fields","aliases":["custom field","custom fields","field","system field"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"extends"},{"entity":"result","via":"extends"}],"capabilities":["create_custom_field_dataset_option_v2","create_custom_field_dataset_v2","create_custom_field_definition_v2","delete_custom_field_dataset_option_v2","delete_custom_field_dataset_v2","delete_custom_field_definition_v2","get_custom_field_dataset_v2","get_custom_field_definition_v2","get_custom_field_values_v1","get_project_id_custom_fields_v2_v1","get_test_results_custom_fields_v1","list_custom_field_dataset_options_v2","list_workspace_fields_v2","update_custom_field_dataset_option_v2","update_custom_field_dataset_project_mapping_v2","update_custom_field_definition_v2"]},"dataset":{"entity":"dataset","title":"Dataset","aliases":["datasets","data-driven-data","dds"],"id_convention":"uuid","parents":["project"],"relations":[{"entity":"test_case","via":"drives"},{"entity":"variable","via":"has columns"}],"capabilities":["create_dataset","delete_dataset","get_dataset","get_dataset_linked_test_cases","get_dataset_variables","import_dataset_c_s_v","list_datasets","update_dataset"]},"duplicate":{"entity":"duplicate","title":"Duplicate recommendation (AI dedupe)","aliases":["duplicate","duplicates","dedupe","deduplication","duplicate recommendation","ai duplicates","redundant test cases"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"links the pair it believes are duplicates"}],"capabilities":["discard_duplicate_v1","get_dedupe_status_v1","get_duplicate_source_test_case_v1","get_duplicate_v1","list_duplicates_v1","resolve_duplicate_v1","search_duplicates_v1"]},"exploratory_session":{"entity":"exploratory_session","title":"Exploratory session","aliases":["session","exploratory","et-session"],"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_v1","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"]},"filter":{"entity":"filter","title":"Saved filter view","aliases":["filter","filters","saved view","saved 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"]},"folder":{"entity":"folder","title":"Folder","aliases":["dir","directory"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"folder","via":"nests under"},{"entity":"test_case","via":"organises"}],"capabilities":["copy_folder","create_bulk_test_cases_v1","create_root_folder_v1","create_sub_folder_v1","delete_folder_and_contents","get_folder_remove_summary","get_folder_tree_v1","get_root_folders_v1","list_folder_contents","move_folder_v1","rename_folder_v1","reorder_folders","undo_remove_folder"]},"project":{"entity":"project","title":"Project","aliases":["pr","workspace"],"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_v1","export_dashboard_analytics","get_active_test_runs_info_v1","get_automation_stats_v1","get_closed_test_runs_info_v1","get_closed_test_runs_split_v1","get_entity_filter_details_v1","get_issues_count_info_v1","get_project_by_integer_id_v1","get_project_settings","get_project_users_v2_v1","get_projects_basic_v1","get_projects_minify_v1","get_test_case_count_trend_v1","get_test_case_type_split_v1","list_projects_v1","search_project_entities","update_project_settings"]},"report":{"entity":"report","title":"Report","aliases":["reports","scheduled-report","schedule","analytics-report"],"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","delete_report_schedule","download_report","get_exploratory_session_summary_v1","get_report_attachment_url","get_report_detail","get_report_section_v1","get_report_summary_by_type","get_selected_report_testcases","list_schedules","send_report_email_now_v1","update_report","user_workload_drilldown"]},"result":{"entity":"result","title":"Result","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_v1","create_step_result_v1","create_test_result_for_test_case","delete_test_result_v1","get_test_results_for_test_case","update_latest_test_result_for_test_case"]},"shared_step":{"entity":"shared_step","title":"Shared step","aliases":["shared step","shared steps","reusable step","step template"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"reused by"}],"capabilities":["create_shared_step_v1","delete_shared_step_v1","get_shared_steps_by_i_d_v1","get_shared_steps_v1","update_shared_step_v1"]},"tag":{"entity":"tag","title":"Tag","aliases":["tags","label","labels"],"id_convention":"name","parents":["project"],"relations":[{"entity":"test_case","via":"labels"},{"entity":"test_run","via":"labels"},{"entity":"test_plan","via":"labels"}],"capabilities":["bulk_edit_test_cases_v2","get_test_case_tags","list_test_case_tags_v1","merge_tags_v1","search_group_tags","search_test_case_tags"]},"test_case":{"entity":"test_case","title":"Test case","aliases":["tc","case","test case"],"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":["bulk_archive_test_cases_by_project","bulk_copy_test_cases","bulk_delete_test_cases","bulk_edit_test_cases","bulk_edit_test_cases_in_test_run","bulk_move_test_cases","bulk_retrieve_test_cases","create_test_case_v1","create_test_cases","edit_test_case_partial_v1","edit_test_case_v1","get_archived_test_cases","get_system_field_values_v1","get_test_case_by_integer_id_v1","get_test_case_detail","get_test_cases_v1","list_folder_test_cases_v1","reorder_test_cases_by_folder_v1","search_project_entities_v2","search_test_cases_by_folder_v1"]},"test_plan":{"entity":"test_plan","title":"Test plan (and sub-plan)","aliases":["tp","plan","milestone","sub test plan","subplan","stp"],"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_v1","bulk_retrieve_test_plans_v1","clone_test_plan_v1","count_test_plan_test_runs_v1","create_test_plan_v1","delete_test_plan_v1","get_archived_test_plan_count_v1","get_test_plan_by_integer_id_v1","get_test_plan_execution_trend_v1","get_test_plan_linked_sessions_chart_data_v1","get_test_plan_test_runs_v1","list_archived_test_plans_v1","list_test_plans_v1","update_test_plan_v1"]},"test_run":{"entity":"test_run","title":"Test run","aliases":["tr","run","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":["assign_test_run_owner","bulk_assign_test_cases_to_test_run","clone_test_run","close_test_run_v1","count_run_selection_v1","create_test_run_v1","delete_v1_test_run","edit_test_run","get_closed_test_runs","get_test_cases_for_v1_test_run","get_test_run_by_integer_id_v1","get_test_run_cases_v1","get_test_runs_form_fields_v1","get_test_runs_selection","get_test_runs_v1"]},"user":{"entity":"user","title":"User","aliases":["users","member","assignee","owner"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"project","via":"member of"},{"entity":"test_run","via":"assigned to"},{"entity":"test_case","via":"owns"}],"capabilities":["get_group_users_v2_v1","search_project_users"]},"version":{"entity":"version","title":"Test case version","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":["get_test_case_histories_v1","get_test_case_history_diff_v1","get_test_case_history_v1","restore_history_v1"]}}}}} \ No newline at end of file +{"schema_version":1,"build_id":"8084a81bd6-173caps-b381220","harness_commit":"b3812207","products":{"tm":{"summary":"BrowserStack Test Management API (v1 \u2014 the SSO/OAuth app API, cookie-authenticated).","capabilities":[{"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"}],"body":[{"name":"owner","type":"integer","description":"The ID of the user to assign as the owner of the test run."}],"intent":"Assign a owner to the test run","guidance":["Assign a user as the owner of a specific test run within a project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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, verified live: 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":["Archive multiple test cases within a project","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"paginated":true},{"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,"description":"Plan INTEGER ids."}],"intent":"Archive test plans in bulk (reversible \u2014 prefer this over delete)","guidance":["REVERSIBLY remove plans from view","Async above the bulk threshold","Soft-archive one or more plans","reach for it whenever the user wants plans \"removed\" or \"cleaned up\" without saying they must be destroyed"],"returns":["async","unique_id"]},{"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"}],"query":[{"name":"include_subfolders_tc","type":"boolean"}],"body":[{"name":"assignee_id","type":"integer","example":2,"description":"ID of the assignee to whom the test cases will be assigned"},{"name":"mapping_ids","type":"array","example":[999,991,1024,1019],"description":"List of test case IDs to be linked"},{"name":"select_all","type":"boolean","example":false,"description":"Flag to select all test cases"},{"name":"de_selected_ids","type":"array","description":"List of test case IDs to be de-selected"},{"name":"folder_id","type":"integer","description":"ID of the folder to which the test cases belong"}],"intent":"Bulk assign test cases to a test run","guidance":["assign specific cases within a run to a user (async above 300)","Assign multiple test cases to a specific test run within a project, allowing for bulk operations on test case assignments"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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` \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, verified live: 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":"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,"json_path":"/test_case/ids"},{"name":"de_selected_ids","type":"array","required":true,"json_path":"/test_case/de_selected_ids"},{"name":"select_all","type":"boolean","required":true,"json_path":"/test_case/select_all"},{"name":"folder_id","type":"string","description":"SOURCE folder that scopes the selection, at top level. OPTIONAL when you pass explicit `ids` (verified live: the copy succeeds without it), but send it whenever `select_all` is true \u2014 it is what bounds the set, and `select_all` with `folder_id` and no `q` copies exactly that folder's cases. Integer and string are both accepted; the declared string matches what the UI sends here, which unlike bulk-"},{"name":"destination_folder_id","type":"integer","required":true,"description":"Target folder for the copies, and the one genuinely required destination field \u2014 omitting it is a bare `400`, verified live. NOTE it is TOP-LEVEL for copy, unlike bulk-move where the destination sits inside `test_case`."},{"name":"destination_project_id","type":"string","description":"Target project id. Needed only for a copy to ANOTHER project. For a copy WITHIN one project it is OPTIONAL \u2014 verified live, omitting it copies correctly into `destination_folder_id`, and integer and string are both accepted. The product does both: its Copy/Move modal sends the source project id, its clone / drag-and-drop path omits it. Send the source id to be explicit or omit it; never invent a v"}],"intent":"Bulk copy test cases","guidance":["copy a selection to a folder (async above 30)","Bulk copy test cases from one folder to another within a project","This operation allows you to copy multiple test cases at once, which can be useful for organizing or duplicating test cases across different folders","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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` \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, verified live: 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 delete.","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":"Delete every case in scope, minus `de_selected_ids`.","json_path":"/test_case/select_all"}],"intent":"Bulk Delete Test Cases from Search","guidance":["All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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."}],"intent":"Delete test results in bulk \u2014 PARTIAL SUCCESS is normal, always read `skipped`","guidance":["Authorisation is enforced per id (a caller must be the result's author or a project admin)","treating a 200 as \"all deleted\" will silently misreport","result_ids must be a non-empty array (400 otherwise) of at most 300 ids (422 beyond that)","batch larger sets yourself"],"returns":["id","reason"]},{"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` \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, verified live: 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 edit.","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":"automation_status","type":"string","json_path":"/test_case/automation_status"},{"name":"case_type","type":"integer","json_path":"/test_case/case_type"},{"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"},{"name":"owner","type":"integer","description":"TM user id.","json_path":"/test_case/owner"},{"name":"preconditions","type":"string","json_path":"/test_case/preconditions"},{"name":"priority","type":"integer","json_path":"/test_case/priority"},{"name":"status","type":"integer","json_path":"/test_case/status"},{"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"}],"intent":"Bulk Update Test Cases","guidance":["bare values, REPLACE-only (async above 100)","Update multiple test cases within a project","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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"}],"query":[{"name":"include_subfolders_tc","type":"boolean"},{"name":"status_id","type":"integer"}],"body":[{"name":"attachments","type":"array","example":[]},{"name":"de_selected_ids","type":"array","example":[]},{"name":"description","type":"string","example":"

something

","description":"HTML formatted comment or note"},{"name":"folder_id","type":"integer","example":21},{"name":"issues","type":"array","example":[]},{"name":"mapping_ids","type":"array","example":[999,991,1024,1019]},{"name":"select_all","type":"boolean","example":false},{"name":"status_id","type":"integer","example":21},{"name":"test_run_ids","type":"array","example":[1001,1002,1003],"description":"Array of BTCER IDs to apply the bulk edit to (passed when automation = true)"},{"name":"test_case_counts","type":"array","example":[1,3,2],"description":"Array of test case counts corresponding to each BTCER ID in test_run_ids (passed when automation = true)"}],"intent":"Bulk edit test cases result in test run","guidance":["Update multiple test cases in a specific test run within a project, allowing for bulk operations such as changing status, adding attachments, and more"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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, verified live: 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},{"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},{"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`.\n\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},{"name":"value","type":"string"}],"json_path":"/test_case/priority"},{"name":"status","type":"string","fields":[{"name":"operation","type":"string","required":true},{"name":"value","type":"string"}],"json_path":"/test_case/status"},{"name":"case_type","type":"string","fields":[{"name":"operation","type":"string","required":true},{"name":"value","type":"string"}],"json_path":"/test_case/case_type"},{"name":"automation_state","type":"string","fields":[{"name":"operation","type":"string","required":true},{"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},{"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},{"name":"value","type":"string"}],"json_path":"/test_case/owner"},{"name":"preconditions","type":"string","description":"Text value.","fields":[{"name":"operation","type":"string","required":true},{"name":"value","type":"string"}],"json_path":"/test_case/preconditions"},{"name":"review_status","type":"string","fields":[{"name":"operation","type":"string","required":true},{"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},{"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":["per-field { value, operation }","Correct for one case too (pass a single id)","Update many test cases in ONE call","Every field is an object of the form {\"value\": \u2026, \"operation\": \u2026}","so this is the only write path that can ADD or REMOVE from a collection without replacing it","one call for N cases instead of N calls"],"returns":["async","unique_id"],"paginated":true},{"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` \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, verified live: 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 move.","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":"Move every case in scope, minus `de_selected_ids`.","json_path":"/test_case/select_all"},{"name":"destination_folder_id","type":"integer","description":"Target folder id. Falls back to `folder_id` when omitted.","json_path":"/test_case/destination_folder_id"},{"name":"folder_id","type":"integer","description":"Legacy alias for `destination_folder_id`, used only when that is absent.","json_path":"/test_case/folder_id"},{"name":"destination_project_id","type":"integer","description":"Set only for a cross-project move. Shared cases cannot be moved across projects (422).","json_path":"/test_case/destination_project_id"}],"intent":"Bulk Move Test Cases","guidance":["Move multiple test cases from one folder to another within a project","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"paginated":true},{"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":"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, verified live: 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 Retrieve Test Cases","guidance":["Retrieve multiple test cases within a project based on specified criteria","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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,"description":"Plan INTEGER ids."}],"intent":"Un-archive test plans in bulk (undo bulk-archive)","guidance":["Restore previously archived plans"],"returns":["async","unique_id"]},{"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":"Clone an exploratory session (async when it has many logs).","guidance":["defects are the issues linked to the session","the parent project takes the INTEGER project id (v1)","List defaults to active sessions"]},{"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","description":"Name for the clone. Defaults to a server-generated copy name.","json_path":"/test_plan/name"},{"name":"copy_all_sub_plans","type":"boolean","json_path":"/test_plan/copy_all_sub_plans"},{"name":"sub_plan_ids","type":"array","description":"Clone only these sub-plans. Ignored when copy_all_sub_plans is true.","json_path":"/test_plan/sub_plan_ids"}],"intent":"Clone a test plan, optionally with its sub-plans","guidance":["copy_all_sub_plans or sub_plan_ids to bring its sub-plans along","Copy an existing plan","copy_all_sub_plans: true clones every sub-plan","alternatively pass sub_plan_ids to clone a specific subset","Omit both to clone just the plan itself"]},{"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"}],"body":[{"name":"copy_tc_assignee","type":"boolean","description":"Copy test case assignees from the original run"},{"name":"copy_issues","type":"boolean","description":"Copy issues linked to the original run"},{"name":"copy_tags","type":"boolean","description":"Copy tags from the original run"},{"name":"copy_all_cases","type":"boolean","description":"Include all test cases in the cloned run"},{"name":"status","type":"array","example":["passed","failed"],"description":"Status strings to filter test cases to copy"},{"name":"status_id","type":"array","example":[1,2],"description":"Status IDs to filter test cases to copy"},{"name":"name","type":"string","example":"Cloned Test Run - 2025","description":"Name for the new cloned test run"}],"intent":"Clone a test run","guidance":["Create a new test run by cloning an existing one, with options to copy test case assignees, issues, tags, and filter cases by status"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"Close an exploratory session","guidance":["Transitions an active exploratory session to the closed state"]},{"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"}],"intent":"Close a test run","guidance":["Close a specific test run within a project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"ID of folder to copy"},{"name":"destination_folder_id","type":"integer","required":true,"example":3,"description":"ID of destination parent folder"},{"name":"destination_project_id","type":"integer","required":true,"example":10000,"description":"Destination project ID (can be same or different)"},{"name":"async","type":"boolean","example":true,"description":"Whether to process asynchronously via background job"},{"name":"copy_test_cases","type":"boolean","example":true,"description":"Whether to copy test cases within folder"}],"intent":"Copy a folder to another location","guidance":["Copies a folder (and optionally its contents) to a destination folder within the same or different project","Supports both synchronous and asynchronous operations via the async flag","Async Processing: When async: true, the operation is queued as a Sidekiq background job and returns immediately with a unique tracking ID"],"returns":["async","folder_id","unique_id"]},{"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","example":false,"description":"Select every case in the project.","json_path":"/selection/select_all"},{"name":"unique_test_case_count","type":"integer","example":2,"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.","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`."},{"name":"configurations","type":"array","required":true,"example":[],"description":"Configuration ids. Required \u2014 pass an empty array if there are none."},{"name":"trtc_configuration_mappings","type":"array","description":"Per-configuration case mappings. An ARRAY of objects \u2014 the server rejects an object here.","fields":[{"name":"configuration_id","type":"integer"},{"name":"select_all","type":"boolean"},{"name":"linked_test_cases","type":"array"},{"name":"unlinked_test_cases","type":"array"}]}],"intent":"Count the test cases a selection resolves to (incl. configurations/datasets).","guidance":["The discovered ids must be CARRIED INTO the create body","discovery alone puts nothing in the run","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","It is validated before the selection","so a bare 400 'invalid data' means a bad test_run field"],"shape":"discovered"},{"method":"GET","path":"/api/v1/projects/{project_id}/test-plans/{test_plan_id}/test-runs/count","mode":"read","entity":"test_plan","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"test_plan_id","type":"integer","required":true}],"intent":"Count the test runs linked to a test plan","guidance":["how many runs a plan groups","cheaper and safer than paging the run list","Returns just the count of runs linked to the plan","Prefer this over paging the run list when the user only asked \"how many\"","list reads truncate around 30 KB and will make you under-count"],"shape":"discovered"},{"method":"POST","path":"/api/v1/projects/{project_id}/folder/{folder_id}/bulk-test-cases","mode":"write","entity":"folder","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"folder_id","type":"integer","required":true}],"body":[{"name":"test_cases","type":"array","required":true,"fields":[{"name":"name","type":"string","required":true},{"name":"test_case_folder_id","type":"string","required":true},{"name":"attachments","type":"array"},{"name":"automation_state","type":"integer"},{"name":"automation_status","type":"string"},{"name":"case_type","type":"integer"},{"name":"custom_fields","type":"object"},{"name":"description","type":"string"},{"name":"estimate","type":"string"},{"name":"estimated_duration_seconds","type":"integer"},{"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"},{"name":"reviewers","type":"array"},{"name":"send_for_review","type":"boolean"},{"name":"shared_precondition_id","type":"integer"},{"name":"status","type":"integer"},{"name":"tags","type":"array"},{"name":"template","type":"string"},{"name":"template_id","type":"integer"},{"name":"template_step_type","type":"string"},{"name":"test_case_dataset","type":"string"},{"name":"test_case_steps","type":"array"}]},{"name":"unique_id","type":"string","description":"Client-supplied idempotency key. When present and the group has the async bulk-create feature flag enabled, the request is processed asynchronously and acknowledged with 202; completion is delivered over the common WebSocket channel keyed by this id."}],"intent":"Create test cases in bulk for a folder (\u226410) \u2014 UNRELIABLE STATUS, see description.","guidance":["Creates several test cases in one request","more returns 400 \"More than permitted test cases sent\"","A 500 here does NOT mean nothing happened","The action is wrapped in a catch-all rescue that renders 500 {\"success\": false, \"message\": \"Failed to create test cases.\"} for any exception, including ones raised AFTER the cases were already inserted","Same status, same message, opposite outcomes","So on a 500: never retry blind"],"returns":["request_trace_id"]},{"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 new configuration for a project","guidance":["there is no flat, account-level list","Each configuration has an INTEGER id","the list returns a configurations[] array plus page info"],"returns":["id","name","created_at","group_id","is_suggested","record_status"]},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"body":[{"name":"option_value","type":"string","required":true,"description":"The option label."},{"name":"is_default","type":"boolean","required":true,"description":"REQUIRED \u2014 a missing value is a 400. Must be 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 parent option this hangs under."}],"intent":"Add one option to a dataset \u2014 how you add a dropdown value.","guidance":["option_value + is_default both required","Adds a single option","Both option_value and is_default are required","a missing is_default is 400 \"Invalid Params\"","For dropdown, at most one option may be the default"]},{"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}],"body":[{"name":"linked_projects","type":"array","description":"Project INTEGER ids this dataset applies to. This is what makes the field visible in a project \u2014 a dataset with no linked projects leaves the field invisible everywhere. NOTE the spelling differs from the project-mappings endpoint, which uses `link_projects` / `unlink_projects`."},{"name":"link_to_future_projects","type":"boolean","description":"Auto-link projects created later."},{"name":"linked_to_all_projects","type":"boolean","description":"Apply to every project in the workspace."},{"name":"options","type":"array","description":"The allowed values, for dropdown-style fields.","fields":[{"name":"option_value","type":"string","required":true},{"name":"is_default","type":"boolean","required":true},{"name":"parent_option_id","type":"integer"}]}],"intent":"Add a dataset (an option set + its project links) to an existing field.","guidance":["A dataset is how a field carries options and project scope","A field can hold more than one, letting different projects see different option sets for the same field","the key is linked_projects, and options is accepted at this level (it is a dataset body, not a field body)","for other types a dataset with linked_projects and no options is what scopes the field to projects"]},{"method":"POST","path":"/api/v1/admin-v2/settings/custom-fields","mode":"write","entity":"custom_field","body":[{"name":"field_name","type":"string","required":true},{"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 (e.g. `field_dropdown`) are a different API's and are rejected. Silently immutable after creation: an update that changes it returns 200 and changes nothing."},{"name":"entity_type","type":"string","values":["TestCase","TestResult","TestPlan","ExploratorySession"],"description":"Which entity the field hangs off. One field belongs to exactly one."},{"name":"is_required","type":"boolean","required":true,"description":"REQUIRED, and must be a literal boolean \u2014 omitting it is a 400."},{"name":"datasets","type":"array","required":true,"description":"REQUIRED for every field type, dropdown or not \u2014 omitting it is a 400. Carries the options and the project scope. Send one entry with `linked_projects` set.","fields":[{"name":"linked_projects","type":"array"},{"name":"link_to_future_projects","type":"boolean"},{"name":"linked_to_all_projects","type":"boolean"},{"name":"options","type":"array"}]},{"name":"place_holder_text","type":"string"},{"name":"default_value","type":"string","description":"Only honoured when `field_type` is `boolean`."},{"name":"levels","type":"array","description":"REQUIRED when field_type is `nested_dropdown`, minimum 2 entries, each {name, level_type}."}],"intent":"Create a custom field DEFINITION (workspace-admin action).","guidance":["options AND project scope both go in datasets[]","Creates the field definition","a new attribute available on the chosen entity type","configurable per workspace","so don't predict access","A caller without it gets 403"]},{"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,"description":"Name of the dataset.","json_path":"/dataset/name"},{"name":"variables","type":"array","required":true,"description":"List of dataset variables/columns (max 40).","fields":[{"name":"name","type":"string","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/variables"},{"name":"rows","type":"array","required":true,"description":"List of dataset rows (max 100).","fields":[{"name":"row_number","type":"integer","required":true},{"name":"row_data","type":"array","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/rows"}],"intent":"Create a new dataset.","guidance":["create a dataset with variables + rows","Create a new dataset with variables and rows for a project"],"returns":["name","created_at","rows_count","uuid"]},{"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.","json_path":"/exploratory_session/title"},{"name":"description","type":"string","example":"Explore edge cases in the checkout flow.","description":"Optional description.","json_path":"/exploratory_session/description"},{"name":"charter","type":"string","example":"Focus on payment error handling","description":"Testing charter describing the scope and goals.","json_path":"/exploratory_session/charter"},{"name":"timebox_duration","type":"integer","example":60,"description":"Planned duration in minutes.","json_path":"/exploratory_session/timebox_duration"},{"name":"owner","type":"integer","example":42,"description":"TCM user ID of the session owner / assignee.","json_path":"/exploratory_session/owner"},{"name":"assignee","type":"integer","example":42,"description":"Alias for `owner`. TCM user ID of the assignee.","json_path":"/exploratory_session/assignee"},{"name":"folder_id","type":"integer","example":456,"description":"ID of the folder to place this session in.","json_path":"/exploratory_session/folder_id"},{"name":"tags","type":"array","example":["regression","mobile"],"description":"Tags for the session.","json_path":"/exploratory_session/tags"},{"name":"configurations","type":"array","example":[1,3],"description":"Configuration IDs to associate with this session.","json_path":"/exploratory_session/configurations"},{"name":"attachments","type":"array","example":[101,102],"description":"Blob / media attachment IDs.","json_path":"/exploratory_session/attachments"},{"name":"issues","type":"array","description":"Issues to link to this session.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/exploratory_session/issues"}],"intent":"Create an exploratory session","guidance":["body wrapped in exploratory_session (title required)","Creates a new exploratory session within the specified project"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"]},{"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 button \u2014 spinner appeared but order was not created.

","description":"Rich-text HTML content of the log entry.","json_path":"/session_log/content"},{"name":"status","type":"string","values":["pass","fail","bug","retest","block","note"],"example":"fail","description":"Test result status for this log step.","json_path":"/session_log/status"},{"name":"elapsed_time","type":"integer","example":120,"description":"Time elapsed for this step in seconds.","json_path":"/session_log/elapsed_time"},{"name":"defects","type":"array","description":"Defects to link to this log entry.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/session_log/defects"}],"intent":"Create a session log entry","guidance":["add a log entry (rich-text HTML, sanitised on write)","Adds a new log entry to the specified exploratory session","Rich-text HTML content is sanitised before storage"],"returns":["id","status","content","created_at","elapsed_time","session_id","updated_at"]},{"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,"description":"Name of the filter"},{"name":"entity","type":"string","required":true,"values":["testcase","testplan","testrun","exploratory_session","test_plan_test_case"],"description":"Entity type the filter applies to. The server's validator accepts five values (the\nlast two were missing here); an unlisted value returns 400 \"Invalid JSON data\"."},{"name":"is_project_level","type":"boolean","description":"Whether this filter is available at project level"},{"name":"filters","type":"object","required":true,"description":"Filter criteria object containing the actual filter conditions"}],"intent":"Create a new filter","guidance":["save a filter view ({ name, entity, filters, is_project_level })","Create a new saved filter for test cases, test plans, or test runs in a project"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"]},{"method":"POST","path":"/api/v1/projects","mode":"write","entity":"project","body":[{"name":"name","type":"string","required":true,"json_path":"/project/name"},{"name":"description","type":"string","json_path":"/project/description"}],"intent":"Create a new project.","guidance":["Pinned to human approval","Create a new project with name and description"],"returns":["name","description"]},{"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},{"name":"report_type","type":"string","required":true,"values":["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","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_statu"},{"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 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`"},{"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;\n`custom_report` is a user-configured one. For a Requirement Traceability\nreport, `system_generated` makes the server auto-populate\n`report_filters.requirements` with every requirement in the project."},{"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"},{"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":"Integer run ids \u2014 pairs with report_timeframe=specific_test_runs.","json_path":"/report_filters/test_runs"},{"name":"test_plans","type":"array","description":"Integer plan ids \u2014 pairs with report_timeframe=specific_test_plans.","json_path":"/report_filters/test_plans"},{"name":"test_cases","type":"string","description":"Case selection \u2014 pairs with report_timeframe=specific_test_cases. FOLDER-KEYED\n(see SelectionData), never a flat id list: the server resolves the selection\nthrough its folder map, so any other shape yields zero cases and saves an\nUNSCOPED, project-wide report at 200.\n\nSend all three keys. Folder ids are STRING keys; test-case ids are INTEGERS\n(the numeric `id`, not the TC-NNN identifier) \u2014 take bo","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":"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.","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 } }.","json_path":"/report_filters/test_plan_selection"},{"name":"builds","type":"array","json_path":"/report_filters/builds"},{"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\n`Test Run Summary`, `Test Run Detailed Report` and `Test Case Activity` \u2014 on\nthe other six report types the server never reads it and drops it silently.\nPersisted (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`."},{"name":"users","type":"array","json_path":"/mail_to/users"},{"name":"external_mails","type":"array","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\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"},{"name":"time","type":"string","example":"08:00","description":"24-hour HH:MM, UTC.","json_path":"/frequency_details/time"},{"name":"day","type":"string","example":"monday","description":"Required for weekly (lowercase day name) and monthly (integer 1-28).\nOmit for daily.","json_path":"/frequency_details/day"}],"intent":"Create a new scheduled report for a project","guidance":["Create a report configuration under the given project"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"]},{"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","description":"Name of the root folder"},{"name":"notes","type":"string","example":"This folder contains all regression test cases","description":"Optional notes or description for the folder"}],"intent":"Create a root-level folder for test cases in a project","returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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},{"name":"folder_id","type":"integer","description":"ID of the shared-field folder to place the shared step in. Null or omitted means the root (Unassigned)."},{"name":"shared_step_details","type":"array","required":true,"fields":[{"name":"order","type":"integer","required":true},{"name":"step","type":"string"},{"name":"result","type":"string"},{"name":"test_data","type":"string"}]}],"intent":"Create a new shared step in a project","guidance":["read the shared step first and send back the full array with your edit applied, or you will drop steps","Include each detail's id to update it in place rather than recreating it","A shared step is embedded by MANY test cases","so editing or deleting it changes every one of them at once","say how it is used before changing it"],"returns":["id","title","step_count","test_case_count"]},{"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},{"name":"test_run_id","type":"string","required":true},{"name":"test_case_id","type":"integer","required":true},{"name":"step_id","type":"integer","required":true}],"body":[{"name":"status_id","type":"integer","json_path":"/test_run_step_result/status_id"},{"name":"description","type":"string","json_path":"/test_run_step_result/description"}],"intent":"Record a per-step result for a case in a run (v1).","guidance":["record a per-step outcome for a step-templated case ({ status_id, description })"]},{"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,"description":"Name of the new folder.","json_path":"/folder/name"},{"name":"notes","type":"string","description":"Description of the new folder.","json_path":"/folder/notes"}],"intent":"create subfolder inside a specific folder","guidance":["Create a new subfolder within a specific folder in a project"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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 the FIRST test case in a project that has no folders (needs create_at_root).","guidance":["Narrow-purpose route: the first case in a project that has zero folders","The route and the flag always travel together","The product's UI derives both from one boolean","no folders exist AND the user picked no folder","A correct refusal, not something to retry: list the folders and pick one","The text sits under errors[], not message"],"returns":["result","step"]},{"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,"fields":[{"name":"name","type":"string","required":true},{"name":"test_case_folder_id","type":"string","required":true},{"name":"attachments","type":"array"},{"name":"automation_state","type":"integer"},{"name":"automation_status","type":"string"},{"name":"case_type","type":"integer"},{"name":"custom_fields","type":"object"},{"name":"description","type":"string"},{"name":"estimate","type":"string"},{"name":"estimated_duration_seconds","type":"integer"},{"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"},{"name":"reviewers","type":"array"},{"name":"send_for_review","type":"boolean"},{"name":"shared_precondition_id","type":"integer"},{"name":"status","type":"integer"},{"name":"tags","type":"array"},{"name":"template","type":"string"},{"name":"template_id","type":"integer"},{"name":"template_step_type","type":"string"},{"name":"test_case_dataset","type":"string"},{"name":"test_case_steps","type":"array"}]},{"name":"create_at_root","type":"boolean"}],"intent":"Create test cases for a folder in a project.","guidance":["body wrapped in test_case, name required","Create one or more test cases within a specific folder in a project","If create_at_root is true, the test case will be created at the root of the project"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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","json_path":"/test_plan/name"},{"name":"description","type":"string","json_path":"/test_plan/description"},{"name":"start_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/start_date"},{"name":"end_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/end_date"},{"name":"plan_status","type":"string","description":"Plan status. The list filter accepts new / started / completed.","json_path":"/test_plan/plan_status"},{"name":"owner","type":"integer","description":"Owner user id \u2014 resolve via the project users read first.","json_path":"/test_plan/owner"},{"name":"parent_plan_id","type":"integer","description":"Parent plan's INTEGER id. Set it to create (or re-parent into) a SUB-plan \u2014 the\nresult carries an STP-NNN identifier. Null/omitted means a top-level plan.","json_path":"/test_plan/parent_plan_id"},{"name":"tags","type":"array","json_path":"/test_plan/tags"},{"name":"reviewers","type":"array","description":"Reviewer user ids.","json_path":"/test_plan/reviewers"},{"name":"attachments","type":"array","description":"Attachment ids already uploaded via the attachments surface.","json_path":"/test_plan/attachments"},{"name":"custom_fields","type":"object","json_path":"/test_plan/custom_fields"},{"name":"issues","type":"array","description":"Linked tracker issues. Structured \u2014 NOT a flat array of keys.","fields":[{"name":"issue_id","type":"string"},{"name":"issue_type","type":"string"},{"name":"metadata","type":"object"}],"json_path":"/test_plan/issues"},{"name":"test_runs","type":"string","description":"The plan's linked test runs. Accepts EITHER an array of test-run integer ids, OR a\nbulk-selection object for \"every run matching this filter\". On update this REPLACES\nthe existing link set rather than appending.","json_path":"/test_plan/test_runs"}],"intent":"Create a test plan \u2014 or a SUB-plan, by setting parent_plan_id","guidance":["body wrapped in test_plan","Create a test plan in the project","Everything goes under the test_plan key","test_runs accepts two shapes","Both are honoured server-side","an unknown option is rejected"]},{"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"},{"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":["The case is in the PATH here, not the body","Bodies are wrapped in test_result","set status by NAME (status) or id (status_id)","use a real configured status (see statuses-and-states)","so a stray string silently CREATES a link","Deleting removes an execution record and the case's latest status is recomputed from what remains, which can silently change the run's reported state"],"returns":["id","status","backtrace","configuration_id","created_at","created_by_imported","custom_fields","description","error_description","expanded","failure_type","finished_at"]},{"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],"description":"Explicit case ids to pin into the run. Top level, NOT inside `test_run`. Use this or `selection`."},{"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":["Create a new test run for a specific project, which can be used to execute test cases"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"dataset_id","type":"integer","required":true},{"name":"option_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"Delete one option (POST to /delete) \u2014 DESTRUCTIVE, drops values using it.","guidance":["destroys the values that used it","Removing an option deletes the stored values that used it, across every project linked to the dataset","that preserves the values"]},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"Delete a dataset (POST to /delete) \u2014 DESTRUCTIVE, drops its options and values.","guidance":["drops its options and the values the linked projects stored","Removes the option set and unlinks its projects, destroying the values those projects had stored for this field","Name the affected projects before calling"]},{"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}],"intent":"Delete a field definition (POST to /delete) \u2014 DESTRUCTIVE, cascades to stored values.","guidance":["destroys every stored value in every linked project","Name the projects first","Removes the definition and every value stored for it, in every project it is linked to","There is no bin and no undo","Before calling: read the field, say how many projects it is linked to, and name them","If the user only wants it hidden rather than destroyed, that is a different ask"]},{"method":"DELETE","path":"/api/v1/projects/{project_id}/datasets/{uuid}","mode":"destructive","entity":"dataset","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"uuid","type":"string","required":true}],"intent":"Delete a dataset.","guidance":["say it isn't available rather than calling it, and don't substitute by parsing the file yourself","A 422 here means NOT-ENTITLED, not a bad payload","say so and stop, don't retry with a different body","BINDING shape is DIFFERENT from the dataset shape, and this is the usual failure","the dataset's uuid repeated on every column it owns","The binding object has NO top-level uuid or name (both stripped by strong params)"]},{"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":"Delete an exploratory session","guidance":["defects are the issues linked to the session","the parent project takes the INTEGER project id (v1)","List defaults to active sessions"]},{"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":"Delete a session log entry","guidance":["Permanently deletes a log entry from the specified exploratory session"]},{"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 filter","guidance":["entity = testcase | testplan | testrun | exploratory_session","filter_id is an integer","reuse a saved view's filters as the q for a later search or bulk action"]},{"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":"Delete a folder and its contents","guidance":["Deletes a folder by its ID and optionally returns the parent folder's path hierarchy"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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"}],"intent":"Delete a report (this removes the report itself, not just its schedule)","guidance":["returns the remaining reports","merely stopping a report from recurring","The response is the list of remaining reports","There is no bin and no undo","so confirm the report's name with the user first"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"]},{"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","guidance":["affects every test case embedding it","Deletes a shared step from a project"]},{"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":"Delete an attachment from a test case in a folder in a project","guidance":["read the file from that url"],"returns":["id","byte_size","content_type","filename"]},{"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":"Delete a test plan (or sub-plan) \u2014 no bin, no undo","guidance":["The server performs no plan-type check","so this deletes a sub-plan by its own integer id exactly the same way","Deleting a parent plan is destructive to its children"]},{"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 test result","guidance":["Prefer PATCHing the latest result to correct a mistake","Deleting a result removes an execution record","the case's latest status is recomputed from what remains"]},{"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"}],"intent":"delete test run","guidance":["The discovered ids must be CARRIED INTO the create body","discovery alone puts nothing in the run","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","It is validated before the selection","so a bare 400 'invalid data' means a bad test_run field"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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},{"name":"duplicate_id","type":"integer","required":true}],"body":[{"name":"discard_reason","type":"string","description":"Short reason code / label for why this isn't a duplicate."},{"name":"user_feedback","type":"string","description":"Free-text feedback from the user."}],"intent":"Discard a duplicate suggestion \u2014 dismisses it WITHOUT touching test cases","guidance":["The safe default when the user says it's not a duplicate or is unsure","Mark a recommendation as discarded","This is the safe way to dismiss a suggestion: it changes only the recommendation's status to discarded and leaves both test cases exactly as they are","discard_reason and user_feedback are optional but feed the dedupe model","pass along whatever reason the user gave"]},{"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":"Desired export format. Note this is a STRING here, while the same-named field\non `send_email_now` is an ARRAY."},{"name":"required","type":"object","description":"Optional column selection, honoured only for `Test Run Detailed Report`\n(ignored for every other type). Shape:\n`{ \"\": { \"fields\": [\"id\",\"title\",...] } }`. Omit to get the report's\ndefault columns."}],"intent":"Initiate a download of a report in a specified file format","guidance":["Request generation and download channel for the specified report"],"returns":["channel"]},{"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"},{"name":"custom_fields","type":"object","example":{"123":"option_value","456":["multi","select"]},"json_path":"/test_case/custom_fields"},{"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"},{"name":"preconditions","type":"string","json_path":"/test_case/preconditions"},{"name":"priority","type":"integer","example":483,"json_path":"/test_case/priority"},{"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\".","json_path":"/test_case/reviewers"},{"name":"status","type":"integer","example":496,"json_path":"/test_case/status"},{"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":"string","description":"Dataset binding for a data-driven case. On this path it is applied only when `is_dataset_modified` is true.","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"}],"intent":"Partially edit a test case (v2)","guidance":["PREFERRED partial update","send ONLY changed fields","Partially update the details of a test case within a specific folder in a project","it is the authoritative list","send test_case_steps instead - estimate is accepted and then dropped","estimated_duration_seconds is the one that persists"],"returns":["result","step"]},{"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"},{"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"},{"name":"custom_fields","type":"object","json_path":"/test_case/custom_fields"},{"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"},{"name":"name","type":"string","json_path":"/test_case/name"},{"name":"owner","type":"integer","json_path":"/test_case/owner"},{"name":"preconditions","type":"string","json_path":"/test_case/preconditions"},{"name":"priority","type":"integer","json_path":"/test_case/priority"},{"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"},{"name":"tags","type":"array","json_path":"/test_case/tags"},{"name":"template","type":"string","json_path":"/test_case/template"},{"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":"string","description":"Dataset binding for a data-driven case. On create the mappings are always regenerated from this, so `is_dataset_modified` is ignored here.","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","json_path":"/test_case/test_case_steps"}],"intent":"Edit a test case","guidance":["Update the details of a test case within a specific folder in a project","Omitted fields are left untouched EXCEPT template, template_id and review_status, which are reset to defaults"],"returns":["result","step"]},{"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"}],"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],"description":"Explicit case ids to pin into the run. Top level, NOT inside `test_run`. Use this or `selection`."},{"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":"Edit Test Run","guidance":["Update the details of a specific test run within a project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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","pdf","excel"],"example":"csv","description":"Export file format"},{"name":"start_date","type":"string","example":"2025-01-01","json_path":"/filters/start_date"},{"name":"end_date","type":"string","example":"2025-12-31","json_path":"/filters/end_date"},{"name":"status","type":"array","example":["passed","failed"],"json_path":"/filters/status"}],"intent":"Export dashboard analytics data","guidance":["Initiates an asynchronous export of dashboard analytics data in the specified format","The export is processed via background job and returns an export ID for tracking","Async Processing: Data export runs via Sidekiq ExportDashboardWorker::FileExportWorker","Supported Formats: CSV, PDF, Excel (based on type parameter)"],"returns":["export_id"]},{"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":"Get active test run result statistics","guidance":["Retrieve statistics of active test runs for a specific project"],"shape":"discovered"},{"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":"Get archived test cases.","guidance":["Retrieve a list of archived test cases in a specific project"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"Count archived test plans","guidance":["Returns {success, count}","the number of archived plans in the project"],"shape":"discovered"},{"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 automation stats analytics","guidance":["Retrieve automation statistics for a specific project"],"returns":["automated_coverage","automated_test_cases","empty_data","manual_test_cases","total_test_cases"]},{"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":"Get all the closed test runs for a project","guidance":["Retrieve a list of all closed test runs for a specific project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"Get closed test run analytics","guidance":["Retrieve statistics of closed test runs for a specific project"],"returns":["month"]},{"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":"Get closed test runs split analytics","guidance":["Retrieve split statistics of closed test runs for a specific project"],"returns":["name"]},{"method":"GET","path":"/api/v1/projects/{project_id}/configurations","mode":"read","entity":"configuration","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Get configurations for a project","guidance":["Retrieve a list of configurations for a specific project"],"returns":["id","name","created_at","group_id","is_suggested","record_status"]},{"method":"GET","path":"/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets/{dataset_id}","mode":"read","entity":"custom_field","path_params":[{"name":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"Read one dataset \u2014 its project links and option set.","guidance":["If nothing matches, the field doesn't exist in that workspace","say so rather than guessing a field id","Multi-dropdowns need OPTION IDS, not labels","That legacy family writes a table local to the Rails app that is DEPRECATED and that nothing reads","It is blocked at the gate","testhub owns definitions"],"shape":"discovered"},{"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}],"intent":"Read one custom field definition, with its datasets and project links.","guidance":["do this BEFORE any update, which is a full replace","Options are NOT inline","Datasets come back WITHOUT their options inline","Read this before any update","so you need the current values to avoid clearing them"],"shape":"discovered"},{"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},{"name":"entity_type","type":"string","required":true,"values":["test-case","test-result","test-plan"]},{"name":"field_id","type":"string","required":true}],"query":[{"name":"q","type":"string"},{"name":"p","type":"integer"}],"intent":"Get the allowed values/options of one custom field.","guidance":["If nothing matches, the field doesn't exist in that workspace","say so rather than guessing a field id","Multi-dropdowns need OPTION IDS, not labels","That legacy family writes a table local to the Rails app that is DEPRECATED and that nothing reads","It is blocked at the gate","testhub owns definitions"],"shape":"discovered","paginated":true},{"method":"GET","path":"/api/v1/projects/{project_id}/datasets/{uuid}","mode":"read","entity":"dataset","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"uuid","type":"string","required":true}],"intent":"Get a specific dataset.","guidance":["read one dataset by UUID","Retrieve a specific dataset by its UUID including all variables and rows"],"returns":["name","created_at","rows_count","uuid"]},{"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},{"name":"uuid","type":"string","required":true}],"query":[{"name":"p","type":"integer"}],"intent":"Get test cases linked to a dataset","guidance":["list the test cases bound to this dataset (paged p)","Returns the test cases linked to a dataset, identified by its UUID"],"shape":"discovered","paginated":true},{"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"},{"name":"p","type":"integer"}],"intent":"Get dataset variables/columns.","guidance":["BINDING shape is DIFFERENT from the dataset shape, and this is the usual failure","the dataset's uuid repeated on every column it owns","The binding object has NO top-level uuid or name (both stripped by strong params)","so do NOT copy the dataset's read shape into it","Never report a dataset as linked from the 200 alone","A dataset is addressed by a UUID (the dataset path param), NOT an integer"],"returns":["name","uuid"],"paginated":true},{"method":"GET","path":"/api/v1/projects/{project_id}/ai-duplicates/status","mode":"read","entity":"duplicate","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Dedupe job status \u2014 use this to tell \"feature off\" from \"no duplicates\"","guidance":["ALWAYS call this when the list is empty","Status of the project's most recent dedupe run"],"returns":["status"]},{"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},{"name":"duplicate_id","type":"integer","required":true}],"intent":"Read the pre-merge snapshot of a MERGED duplicate's source test case","guidance":["after a merge, read the pre-merge snapshot of the case that was folded away","After a merge, the source case no longer exists independently","this returns the snapshot captured at merge time","the only way to show the user what was folded away","Only works for status merged (404 otherwise), and the snapshot may legitimately be absent, which also surfaces as a 404 with error: \"Source test case snapshot not available\"","Treat that as \"not retained\", not as an error to retry"],"shape":"discovered"},{"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},{"name":"duplicate_id","type":"integer","required":true}],"intent":"Read one suggested duplicate, with its test cases resolved","guidance":["do this BEFORE merging so the user can see what would be destroyed","Only status=suggested is readable","Full detail for one recommendation, including the actual test cases it links","so you can show the user what would be merged","Only status suggested is readable here","already-resolved duplicates return 404"],"shape":"discovered"},{"method":"GET","path":"/api/v1/projects/{project_id}/{entity}/filter-details","mode":"read","entity":"project","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"entity","type":"string","required":true,"values":["test-cases","trtc","test-runs"],"example":"test-cases"}],"query":[{"name":"q[query]","type":"string","example":"login functionality"},{"name":"q[folder_ids]","type":"string","example":"3306878,3669741,5422801,5422802"},{"name":"q[status]","type":"string","example":"9319,9321"},{"name":"q[priority]","type":"string","example":"550376,9261"},{"name":"q[automation_state]","type":"string","example":"18172471,18172473"},{"name":"q[case_type]","type":"string","example":"9379,9375"},{"name":"q[owner]","type":"string","example":"user123,user456"},{"name":"q[assignee]","type":"string","example":"user789,user101"},{"name":"q[tags]","type":"string","example":"regression,smoke"},{"name":"q[created_at]","type":"string","example":"2024-01-01..2024-12-31"},{"name":"q[updated_at]","type":"string","example":"2024-01-01..2024-12-31"}],"intent":"Get filter details for entity","guidance":["Retrieve filter details for a specific entity type (test-cases, trtc, or test-runs) within a project"],"returns":["id","colour","entity_type","field_name","field_value","internal_name"]},{"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":"Get an exploratory session","guidance":["read one session by INTEGER id","Returns a single exploratory session by its ID"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"]},{"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"]},{"name":"start_date","type":"string"},{"name":"end_date","type":"string"},{"name":"session_ids","type":"string"}],"intent":"Exploratory Session Summary \u2014 live view, no saved report needed","guidance":["exploratory-session summary WITHOUT creating a report","Computes an exploratory-session summary on demand","there is no Schedule row behind it","Use it to answer \"summarise our exploratory sessions\" without creating a report first","Returns session counts, bugs found, top testers (resolved to user objects) and the session list"],"shape":"discovered"},{"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":"Get filter details","guidance":["Retrieve details of a specific saved filter by its ID","It does NOT validate or strip invalid custom-field references","a filter saved with a non-existent custom-field id is returned unchanged","A missing or deleted filter comes back as 400 {\"success\": false, \"message\": \"Filter not found\"}, not 404"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"]},{"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":"Get summary of entities to be deleted when removing a folder","guidance":["PREVIEW what deleting a folder will remove before calling rm","Provides a summary of all entities (test cases, recordings, sub-folders) that would be deleted if the folder is removed"],"returns":["active_test_case_count","archived_test_case_count","sub_folders","test_recordings_count"]},{"method":"GET","path":"/api/v1/projects/{project_id}/repository/mapping","mode":"read","entity":"folder","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Get the project's folder tree (v1).","guidance":["the whole project folder TREE in one call (structure array)"],"shape":"discovered"},{"method":"GET","path":"/api/v1/group/users-v2","mode":"read","entity":"user","query":[{"name":"q","type":"string"}],"intent":"Get group users V2 with pagination","guidance":["Retrieve a paginated list of users in the group with search and filtering capabilities, WITHOUT project scoping","used by the Global Search filter dropdowns","Search by user IDs (comma-separated) or by a name string"],"returns":["id","browserstack_user_id","email","full_name","group_id","onboarded","reports_and_notification_enabled"]},{"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":"Get issues count analytics","guidance":["Retrieve the count of issues for a specific project"],"returns":["label"]},{"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","guidance":["so for an integer id this is the right read","Do NOT translate first just to fetch the project","It serves two jobs at once: (1) READ","returns the project's full detail (name, description, permissions, \u2026)","In other words it is NOT translation-only"],"returns":["id","identifier","name","created_at","description","jira_mapped","starred","test_cases_count","test_plans_count","test_runs_count","thProjectId","user_role"]},{"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}],"intent":"Get project-specific custom fields V2 for test cases","guidance":["START HERE for CUSTOM fields","Does NOT expose system-field option ids","Retrieve custom fields, default fields, and system fields for test cases in a specific project (V2 format)"],"returns":["id","default_value","entity_type","field_name","field_type","group_id","is_bulk_editable","is_filterable","is_required","linked_projects_count","place_holder_text","system_name"]},{"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"]}],"intent":"Get project settings","guidance":["Returns an array of enabled setting keys"],"returns":["in_review_count","test_plan_in_review_count"]},{"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"}],"intent":"Get project users V2 with pagination","guidance":["Retrieve paginated list of users in the group with search and filtering capabilities","Search by user IDs (comma-separated) or by name string"],"returns":["id","browserstack_user_id","email","full_name","group_id","onboarded","reports_and_notification_enabled"]},{"method":"GET","path":"/api/v1/projects/basic","mode":"read","entity":"project","query":[{"name":"p","type":"integer"},{"name":"count","type":"integer"},{"name":"q[query]","type":"string","example":"nishchay3"},{"name":"q[identifier]","type":"string","example":"PR-60863"}],"intent":"Get paginated projects (lean payload) \u2014 use this to list or count projects","guidance":["so you can reach a true total without truncating","Those two are the only recognised keys"],"returns":["id","name","description","import_id","normalisedName","starred","thProjectId"],"max_page_size":300,"paginated":true},{"method":"GET","path":"/api/v1/projects/minify","mode":"read","entity":"project","query":[{"name":"p","type":"integer"},{"name":"count","type":"integer"}],"intent":"Get all projects (minified dropdown) \u2014 has NO name search","guidance":["never use it to resolve a named project or to count them","Minified list of active projects, for dropdown-style selection","It accepts no q in any form","the backend call has no query option","so it can only page through everything","Do NOT use it to resolve a project the user named"],"returns":["id","name","description","import_id","normalisedName","starred","thProjectId"],"max_page_size":300,"paginated":true},{"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},{"name":"attachment_id","type":"integer","required":true}],"intent":"Get download URL for a report attachment","guidance":["Retrieve a secure URL to download a specific report attachment"],"returns":["url"]},{"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"},{"name":"report_data_only","type":"boolean"},{"name":"p","type":"integer"},{"name":"today","type":"string","example":"2025-05-13"}],"intent":"Retrieve a scheduled report's config and data \u2014 the general report read","guidance":["works for every report type","Full configuration plus computed data for a report of ANY type","request the section and look at the result","which is a real finding","Pass sections to compute the sections you need","several in ONE call, cheaper than one request per section"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"],"paginated":true},{"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"}],"query":[{"name":"widget_type","type":"string"},{"name":"segment","type":"string"},{"name":"p","type":"integer"}],"intent":"Fetch one report widget/section \u2014 works for EVERY report type.","guidance":["Section names are validated per type","*_drilldown sections return paginated ROWS, not aggregates","Returns a single section's data for any report type","This is the general section read","so when you need SEVERAL sections, prefer that form with a comma-separated sections list and take them in one call rather than one request per section","Most sections return the computed aggregate for a widget"],"shape":"discovered","paginated":true},{"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","requirement_traceability_report"]}],"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":"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`"},{"name":"sections","type":"string","example":"test_run_data,test_run_status"},{"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"]},{"name":"segment","type":"string"},{"name":"p","type":"integer"}],"intent":"Get summary statistics for a scheduled report \u2014 run and traceability types ONLY","guidance":["400s on every other type","so not for Test Case Activity or Test Plan Summary","including every other type in ReportType","This is not the general report-read","optionally with sections","reportTimeRange is required"],"shape":"discovered","paginated":true},{"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 of the folder to search for"},{"name":"include_folder_hierarchy","type":"boolean","description":"Whether to include folder hierarchy in the response"},{"name":"p","type":"integer"},{"name":"per_page","type":"integer"}],"intent":"Get all folders and subfolders present in the projects.","guidance":["list root folders (paged with p","Returns all folders and subfolders in a project if it exists in TestHub"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"],"max_page_size":100,"paginated":true},{"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":"Get already selected testcases for a tracebility report","guidance":["Defects Summary and Defects Detailed Report are accepted on create but have no data branch","AND it requires reportTimeRange: omitting it is a 500, not a 400","Section names are per-report-type","a name from another type is a 400","so one bad name rejects the whole call without saying which","See the reports concept for the per-type lists"],"returns":["select_all","unique_test_case_count"]},{"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":"paginated","type":"boolean"},{"name":"p","type":"integer"}],"intent":"Get shared steps","guidance":["read one shared step with its ordered step details","Retrieves a list of shared steps for a project"],"returns":["id","title"],"paginated":true},{"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"},{"name":"q[query]","type":"string"},{"name":"pre_fetch","type":"boolean"}],"intent":"Get all shared steps for a project","guidance":["Returns a list of all shared steps within a project"],"returns":["id","title","step_count","test_case_count"],"paginated":true},{"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"},{"name":"p","type":"integer"}],"intent":"THE source for a system field's option ids (priority/status/case_type/automation_state).","guidance":["a single attachment-URL field is enough to push the response past the ~30 KB cap","Supports q to search the option list and p to page it, which matters","a workspace can accumulate dozens of options on a single field"],"shape":"discovered","paginated":true},{"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":"Get all attachments for a test case in a folder in a project","guidance":["list a case's attachments","Retrieve all attachments associated with a specific test case within a folder","CURRENTLY RETURNING 500","a reproducible server error, not a bad request","Do NOT retry it and do not report it to the user as their mistake"],"returns":["id","byte_size","content_type","filename"]},{"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":"Get a test case by INTEGER id (v1, folder-scoped) \u2014 full detail + its TC-NNN identifier","guidance":["so for an integer id this v1 read is the ONLY way to fetch the case","Two jobs at once: (1) READ","NOT translation-only","Don't fall back to listing the folder to find the case: if you have the integer id, read it here directly"],"returns":["id","identifier","title"]},{"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":"Get test case count trend analytics","guidance":["Retrieve the trend of test case counts for a specific project"],"returns":["field"]},{"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","example":"folder_path"}],"intent":"Get Test Case Details","guidance":["full detail (steps, custom fields, issues) before editing","read this, improve, then write back"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"Get test case histories","guidance":["list all version records for a test case","Retrieve all history records for a specific test case"],"returns":["id","comment","created_at","entity_id","entity_type","group_id","project_id","source","user_id","version_id","version_name"]},{"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}],"intent":"Get test case history diff","guidance":["compare two versions","versions[] with exactly two ids (PAID plan)","Retrieve the diff of a specific history record for a test case"],"returns":["id","order","result","shared_step_id","step"]},{"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":"Get a specific test case history","guidance":["Retrieve details of a specific test case history by its ID"],"returns":["id","comment","created_at","entity_id","entity_type","group_id","project_id","source","user_id","version_id","version_name"]},{"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":"Get tags and metadata for a test case","guidance":["read a case's current tags","Retrieve the tags and metadata associated with a specific test case in a project"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"Get test case type split analytics","guidance":["Retrieve the split of test case types for a specific project"],"returns":["name","field","value","y"]},{"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"}],"query":[{"name":"required[fields]","type":"string","values":["count"]}],"intent":"Get paginated test cases for a test run","guidance":["page the cases in a run (each row is the case you log results against)","Retrieve a paginated list of test cases for a specific test run in a project"],"shape":"discovered"},{"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"},{"name":"per_page","type":"integer"},{"name":"required[fields]","type":"string","example":"owner,priority"},{"name":"required[custom_fields]","type":"string","example":"121,119"},{"name":"all_folders","type":"string","values":["true"],"example":"true"}],"intent":"List a project's test cases \u2014 REQUIRES all_folders=true to be project-wide.","guidance":["Without all_folders=true this returns only ONE folder's cases","the project's first root folder","So a project-wide question answered from a bare call silently under-counts by whatever sits in every other folder, and nothing in the response says so","all_folders is compared as the STRING 'true' (all_folders = params['all_folders'] == 'true')","A JSON boolean true, 1, TRUE or any other value all fail that check and silently fall back to the single-folder scope"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"max_page_size":100,"paginated":true},{"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":"Get a test plan by INTEGER id (v1) \u2014 full detail + its TP-NNN identifier","guidance":["READ one plan by INTEGER id","Read a test plan by its INTEGER id (project integer id in the path)","Don't translate first just to read the plan","this returns its full detail directly","Two jobs at once: (1) READ","the plan's full detail (under test_plan"],"returns":["identifier","name"]},{"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"},{"name":"test_run_id","type":"integer"},{"name":"today","type":"string"}],"intent":"Execution-trend chart data for ONE plan or ONE run (XOR)","guidance":["pass test_plan_id XOR test_run_id (both or neither is a 400)","Time-series execution trend backing the Insights tab chart","Scope it with exactly one of test_plan_id or test_run_id","Passing both returns 400 \"Provide either test_plan_id or test_run_id, not both\"","passing neither returns 400 \"test_plan_id or test_run_id is required\"","This returns chart data, not a widget object"],"shape":"discovered"},{"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 a test plan","guidance":["the other half of the Insights tab","Like execution-trend, this is chart data only","insights widgets themselves are not a CRUD resource here"],"shape":"discovered"},{"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"},{"name":"include_sub_test_plan_runs","type":"boolean"},{"name":"compact","type":"boolean"},{"name":"is_archived","type":"boolean"}],"intent":"List the test runs linked to a test plan","guidance":["Paginated list of the runs a plan groups","that field replaces the link set rather than appending to it","include_sub_test_plan_runs=true also returns runs belonging to the plan's sub-plans"],"shape":"discovered","paginated":true},{"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}],"query":[{"name":"p","type":"integer"},{"name":"per_page","type":"integer"}],"intent":"Get paginated custom fields for test results","guidance":["Retrieve paginated list of custom fields configured for test results in a project"],"returns":["id","default_value","entity_type","field_name","field_type","field_user_name","is_bulk_editable","is_filterable","is_required","optional","placeholder"],"max_page_size":100,"paginated":true},{"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"},{"name":"test_case_id","type":"integer","required":true}],"intent":"Get test results for a test case in a test run","guidance":["read a case's results within a run","Retrieve a list of test results for a specific test case within a test run in a project"],"returns":["id","status","backtrace","configuration_id","created_at","created_by_imported","custom_fields","description","error_description","expanded","failure_type","finished_at"]},{"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":["Read a test run by its INTEGER id (with the project's integer id in the path)","Don't translate first just to read the run","this returns its full detail directly","Two jobs at once: (1) READ","NOT translation-only"],"returns":["id","identifier","name","uuid"]},{"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},{"name":"test_run_id","type":"string","required":true}],"intent":"Get a run with its per-case rows (v1).","guidance":["the run with its per-case rows (did it pass?)"],"shape":"discovered"},{"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"}],"intent":"Get custom field values for test results in test runs","guidance":["Retrieve default field values and optionally custom fields for test results (TRTC - Test Run Test Case)"],"returns":["id","applies_to_all_projects","default_value","entity_type","field_name","field_type","is_required","link_to_future_projects","place_holder_text"]},{"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.","json_path":"/selection/select_all"},{"name":"unique_test_case_count","type":"integer","example":2,"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.","json_path":"/selection/folders"},{"name":"shared_selection","type":"object"}],"intent":"get selected test runs of a project","guidance":["Retrieve selected test runs for a specific project based on the provided selection criteria"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"ignore_run_type","type":"boolean"},{"name":"include_test_plan","type":"boolean"}],"intent":"Get all the test runs for a project","guidance":["list the project's (open) runs, paged with p","Retrieve a list of all test runs for a specific project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"Import a CSV dataset \u2014 NOT SUPPORTED in this profile","guidance":["say CSV import isn't available","CSV import is not supported here","If asked to import a dataset from CSV, say it isn't available and point to the Test Management UI"]},{"method":"GET","path":"/api/v1/activities","mode":"read","entity":"activity","query":[{"name":"project_id","type":"integer"},{"name":"cursor","type":"string"},{"name":"limit","type":"integer"},{"name":"actor_id","type":"integer"},{"name":"starred_only","type":"boolean"},{"name":"entity_type","type":"array"}],"intent":"Read the activity feed (audit trail) \u2014 CURSOR paged, 15-day window","guidance":["WHO changed WHAT recently","group-wide audit trail","Group-wide audit trail of who changed what","Read-only: activity events cannot be created, edited, or deleted","Two things differ from every other list in this profile: - Cursor pagination, not page numbers","so you cannot jump to a page or read a total"],"returns":["no_starred_projects"]},{"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"}],"intent":"List archived test plans","guidance":["where a 'missing' plan usually is, and the source of ids for bulk-retrieve","Paginated list of the project's archived plans","so if a plan the user names seems missing, look here before concluding it was deleted"],"shape":"discovered","paginated":true},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"List a dataset's options with their ids.","guidance":["the ids a filter or a test-case write needs"],"shape":"discovered"},{"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"},{"name":"q[uuid]","type":"string"},{"name":"p","type":"integer"}],"intent":"List datasets for a project.","guidance":["list datasets (paged p","Retrieve a paginated list of datasets for a project with optional filtering by name or UUID"],"returns":["name","created_at","rows_count","test_cases_count","updated_at","uuid"],"paginated":true},{"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"},{"name":"entity_type","type":"string"},{"name":"entity_id","type":"integer"},{"name":"duplicates","type":"string"}],"intent":"List AI-suggested duplicate test cases \u2014 an empty list may mean the feature is OFF","guidance":["LIST the duplicate suggestions awaiting review","List the AI dedupe recommendations awaiting review in a project","Only duplicates with status suggested are returned","once merged, archived, or discarded they leave this list","An empty result is ambiguous","identical to \"this project has no duplicates\""],"shape":"discovered","paginated":true},{"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":"page","type":"integer","example":1},{"name":"per_page","type":"integer","example":30}],"intent":"List defects for an exploratory session","guidance":["List defaults to active sessions","the parent project takes the INTEGER project id (v1)","defects are the issues linked to the session"],"returns":["issue_id","issue_type"],"max_page_size":100,"paginated":true},{"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":"page","type":"integer","example":1},{"name":"per_page","type":"integer","example":50}],"intent":"List logs for an exploratory session","guidance":["page the session's log entries","Returns a paginated list of log entries for the specified exploratory session"],"returns":["id","status","content","created_at","elapsed_time","session_id","updated_at"],"max_page_size":100,"paginated":true},{"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":"page","type":"integer","example":1},{"name":"per_page","type":"integer","example":30},{"name":"q[session_state]","type":"string","values":["active","closed"],"example":"active"},{"name":"q[assignee]","type":"integer","example":42},{"name":"q[owner]","type":"integer","example":42},{"name":"q[created_by]","type":"integer","example":10},{"name":"q[created_at_from]","type":"string","example":"2026-01-01"},{"name":"q[created_at_to]","type":"string","example":"2026-01-31"},{"name":"q[tags][]","type":"array","example":["regression","payment"]},{"name":"q[configuration_ids][]","type":"array","example":[1,2]},{"name":"q[search]","type":"string","example":"checkout"},{"name":"q[status]","type":"string","example":"pass"}],"intent":"List exploratory sessions for a project","guidance":["list sessions (defaults to active","Returns a paginated list of exploratory sessions"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"],"max_page_size":100,"paginated":true},{"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","type":"string","required":true,"values":["testcase","testplan","testrun","exploratory_session","test_plan_test_case"]},{"name":"page","type":"integer"}],"intent":"List filters","guidance":["list saved filter views (optional entity = testcase|testplan|testrun|exploratory_session)","Retrieve a paginated list of saved filters for a project, optionally filtered by entity type"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"],"paginated":true},{"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":"List subfolders and folder metadata for a specific folder in a project","guidance":["one folder's detail + its direct subfolders + ancestors"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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"},{"name":"per_page","type":"integer"},{"name":"required[fields]","type":"string","example":"owner,priority"},{"name":"required[custom_fields]","type":"string","example":"121,119"}],"intent":"List the test cases in one folder","guidance":["Page through the cases directly inside a folder, by the folder's INTEGER id","Use this when the user has already named or selected a folder","Rows are verbose and list reads truncate around 30 KB"],"shape":"discovered","max_page_size":100,"paginated":true},{"method":"GET","path":"/api/v1/projects","mode":"read","entity":"project","query":[{"name":"q","type":"string","example":"nishchay3"},{"name":"p","type":"integer"},{"name":"sort_by","type":"string"},{"name":"starred","type":"boolean"},{"name":"shared","type":"boolean"},{"name":"is_hidden_projects","type":"boolean","example":true}],"intent":"List projects for a group.","guidance":["Paginated list of the group's projects","query and page are not read by the server","so use this to FIND a project, not to enumerate them"],"returns":["id","identifier","name","created_at","description","import_id","project_creator","test_cases_count","test_runs_count"],"paginated":true},{"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"},{"name":"q[scheduled]","type":"string","values":["true","false"]},{"name":"p","type":"integer"}],"intent":"List all the scheduled reports","guidance":["list the project's report schedules (paged p","Retrieve a paginated list of schedules"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"],"paginated":true},{"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"},{"name":"q","type":"string"}],"intent":"List test-case tags (paginated).","guidance":["list a project's test-case tags (paged with p, optional q)"],"shape":"discovered","paginated":true},{"method":"GET","path":"/api/v1/admin-v2/settings/templates","mode":"read","entity":"","query":[{"name":"entity_type","type":"string","values":["TestCase","TestResult","TestPlan","ExploratorySession"]},{"name":"project","type":"integer"},{"name":"name","type":"string"},{"name":"p","type":"integer"}],"intent":"List the workspace's test-case templates \u2014 THE source for `template_id`.","guidance":["Ids are per-workspace","so one carried over from another workspace (or from an example) produces the generic 400 \"The API request is invalid or improperly formed\" on a test-case create, with nothing naming the offending field","Call this when the user asked for a named template, or to confirm a template id before reusing one","One call therefore yields both fields","NOTE THE CASING: entity_type here is CamelCase (TestCase), unlike the hyphenated test-case used in path segments elsewhere","Passing test-case returns an empty list with a 200"],"shape":"discovered","paginated":true},{"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"},{"name":"include_sub_plans","type":"boolean"},{"name":"status","type":"string","values":["new","started","completed"]},{"name":"sort_by","type":"string"},{"name":"minify","type":"boolean"}],"intent":"List test plans in a project (optionally including sub-plans)","guidance":["include_sub_plans=true to see sub-plans too","Paginated list of the project's test plans","This is how you find a plan's integer id before reading, updating, or deleting it","so never try to find a plan through search","Without it you see only top-level plans and a plan's children are invisible"],"shape":"discovered","paginated":true},{"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"]},{"name":"field_source","type":"string","values":["system","custom","all"]},{"name":"field_type","type":"string"},{"name":"q","type":"string"},{"name":"p","type":"integer"}],"intent":"THE canonical workspace field list (system + custom) \u2014 start here for definitions.","guidance":["workspace-wide list of DEFINITIONS across all projects","'does a field called X exist anywhere?'","The authoritative list (testhub-backed)","This is the authoritative definition list: it is backed by testhub, which owns custom-field definitions","That legacy collection reads a DEPRECATED table local to the Rails app that nothing else consults","its contents are unrelated to the real fields"],"shape":"discovered","paginated":true},{"method":"POST","path":"/api/v1/projects/{project_id}/tags/merge","mode":"write","entity":"tag","path_params":[{"name":"project_id","type":"integer","required":true}],"body":[{"name":"source_id","type":"integer","required":true},{"name":"target_id","type":"integer","required":true},{"name":"entity_type","type":"string","required":true,"values":["test_case","test_run","test_plan","shared_step"]}],"intent":"Merge one tag into another (v1). Admin-gated (tags_management).","guidance":["merge one tag into another ({ source_id, target_id, entity_type })"]},{"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","required":true,"example":123,"description":"ID of the new parent folder (internal APIs)"},{"name":"new_base_project_id","type":"integer","example":456,"description":"Optional destination project ID for cross-project moves"},{"name":"user_action","type":"string","example":"move_folder"}],"intent":"Move a test case folder to a different parent folder","guidance":["move a folder ({ new_base_folder_id, new_base_project_id })","cross-project moves run async","Move a test case folder to a new parent folder within the same project","This operation updates the folder's hierarchy without changing its contents"],"returns":["async","unique_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","required":true,"description":"Name of the new folder.","json_path":"/folder/name"},{"name":"notes","type":"string","description":"Description of the new folder.","json_path":"/folder/notes"}],"intent":"rename a folder in a project","guidance":["Rename an existing folder within a specific project"],"returns":["request_trace_id"]},{"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],"description":"List of folder IDs to reorder"},{"name":"prev","type":"integer","example":101,"description":"ID of the folder that will precede the moved folder"},{"name":"next","type":"integer","example":103,"description":"ID of the folder that will follow the moved folder"}],"intent":"Reorder folders in a project","guidance":["Reorder folders within a project by specifying the current order and optionally the previous and next folder IDs"],"returns":["request_trace_id"]},{"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":"ID of the test case that will be above the moved ones.","json_path":"/re_order/top_test_case"},{"name":"bottom_test_case","type":"string","description":"ID of the test case that will be below the moved ones.","json_path":"/re_order/bottom_test_case"},{"name":"page","type":"integer","description":"Page number for paginated reordering.","json_path":"/re_order/page"},{"name":"test_cases","type":"array","required":true,"description":"Array of test case IDs to be moved.","json_path":"/re_order/test_cases"}],"intent":"Reorder test cases within a folder of a project","guidance":["Reorders test cases in a folder by placing them between top_test_case and bottom_test_case"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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. Passed in the BODY, not the path."},{"name":"merge_action","type":"string","required":true,"description":"`merge` folds source into target; any other value archives."},{"name":"source_testcase_id","type":"integer","description":"Required for merge \u2014 the case that will cease to exist independently."},{"name":"target_testcase_id","type":"integer","description":"Required for merge \u2014 the case that survives."}],"intent":"Resolve a duplicate by MERGING or ARCHIVING \u2014 destroys or hides a test case","guidance":["RESOLVE a suggestion","merge_action=merge folds source_testcase_id into target_testcase_id (DESTRUCTIVE to a test case), anything else archives","duplicate_id goes in the BODY","Act on one dedupe recommendation","merge_action selects what happens: - \"merge\"","folds source_testcase_id into target_testcase_id"]},{"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":"Restore a specific history entry","guidance":["roll the case back to this version (PAID plan","Restore a specific history entry by its ID"]},{"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":"page","type":"integer"},{"name":"duplicates","type":"string","description":"Duplicate-type filter."},{"name":"owner","type":"string","description":"Owner tab \u2014 scopes to the caller's own duplicates vs all."}],"intent":"Filtered search over suggested duplicates","guidance":["filtered search over suggestions (owner tab, duplicate type, test-case attributes)","Subject to the identical flag caveat: an empty list may mean the feature is off rather than that there are no duplicates"],"shape":"discovered","paginated":true},{"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"}],"query":[{"name":"q","type":"string"},{"name":"p","type":"integer"}],"intent":"Search group tags for an entity type","guidance":["search tags across the whole group (entity_type = test-case|test-run|test-plan)","Retrieve a paginated list of tags for the given entity type across the group (no project scoping)","used by the Global Search filter dropdowns"],"returns":["name","created_at","usage_count"],"paginated":true},{"method":"GET","path":"/api/v1/projects/{project_id}/{entity}/search","mode":"read","entity":"project","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"entity","type":"string","required":true,"values":["test-cases","test-runs","test-plans","reports"]}],"query":[{"name":"q[query]","type":"string","example":"tc"},{"name":"q[folder_id]","type":"integer","example":29529465},{"name":"use_bstack_id","type":"string","values":["0","1"]},{"name":"p","type":"integer"},{"name":"page_size","type":"integer"},{"name":"include_test_plan","type":"boolean"}],"intent":"Search for entities within a project","guidance":["Returns paginated results matching the search criteria"],"shape":"discovered","max_page_size":100,"paginated":true},{"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"},{"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 "},{"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":["Note: Array values in the q parameter are automatically converted to comma-separated strings for compatibility with existing search logic"],"shape":"discovered","max_page_size":100,"paginated":true},{"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"},{"name":"p","type":"integer"},{"name":"page_size","type":"integer"}],"intent":"Search users with access to a project","guidance":["Retrieves a paginated list of users who have access to the specified project","Returns user details including permissions, feature flags, and product roles"],"returns":["id","browserstack_user_id","customisable_dashboard_accessible","full_name","group_id","has_access","is_build_listing_enabled","is_delighted_visible","is_jira_app_project_panel_visible","is_lcnc_explore_dismissed","is_new_project_settings_visible","is_onboarding_project_header_visible"],"max_page_size":100,"paginated":true},{"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"},{"name":"p","type":"integer"},{"name":"page_size","type":"integer"}],"intent":"Search test case tags","guidance":["Retrieves a paginated list of tags associated with test cases in the project","Returns both simple tag strings and detailed tag objects with metadata"],"returns":["name","created_at","usage_count"],"max_page_size":100,"paginated":true},{"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":"query","type":"string"},{"name":"use_bstack_id","type":"string","values":["0","1"]}],"intent":"Search test cases in a project","guidance":["and see pagination-and-filtering for the key table and each value's source","there is NO top-level values array, and looking for one finds nothing","so it is routinely cut off and appears absent","NEVER probe candidate integers for an id","The four system option fields","a name silently returns 0 because the server matches an id column"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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","example":["qa-leads@company.com"],"description":"Recipient addresses. MUST be an array, even for one address."},{"name":"file_type","type":"array","example":["pdf"],"description":"Formats to attach. MUST be an array. Defaults to [\"pdf\"]."}],"intent":"Email a report immediately (async job).","guidance":["Kicks off a background send","a 200 means the job was queued, NOT that mail was delivered","don't report it as sent","Both body fields are ARRAYS and are rejected with 400 \" should be an array\" if sent as scalars","Omitting emails sends to the report's configured recipients","omitting file_type defaults to [\"pdf\"]"]},{"method":"POST","path":"/api/v1/projects/{project_id}/folder/{folder_id}/undo-rm","mode":"write","entity":"folder","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"folder_id","type":"integer","required":true}],"intent":"Undo folder deletion","guidance":["Restores a previously deleted folder and returns its metadata along with path hierarchy"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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":"dataset_id","type":"integer","required":true},{"name":"option_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"body":[{"name":"option_value","type":"string","required":true,"description":"The option label."},{"name":"is_default","type":"boolean","required":true,"description":"REQUIRED \u2014 a missing value is a 400. Must be 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 parent option this hangs under."}],"intent":"Rename an option or change its default (POST to /update).","guidance":["NON-destructive, stored values follow the option id","Both option_value and is_default are required","a missing is_default is a 400","Renaming an option keeps the stored values pointing at it (the option id is unchanged)","so this is the NON-destructive way to fix a label"]},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"body":[{"name":"link_projects","type":"array","description":"Project INTEGER ids to ADD. Note the spelling \u2014 not `linked_projects`."},{"name":"unlink_projects","type":"array","description":"Project INTEGER ids to REMOVE. Destroys their stored values."},{"name":"link_to_future_projects","type":"boolean"},{"name":"select_all","type":"boolean","description":"Apply to every project; may switch the call to async."}],"intent":"Link or unlink projects for a dataset \u2014 how you change which projects see a field.","guidance":["Unlinking destroys that project's values","The keys here are link_projects and unlink_projects","Send the wrong spelling and it is silently dropped: 200, nothing changed","This is the single easiest mistake on this surface","These are DELTAS, not the complete new list: send only the projects to add and the projects to remove","Unlinking is destructive"]},{"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,"description":"Display label. Editable. REQUIRED even when unchanged."},{"name":"field_type","type":"string","required":true,"description":"REQUIRED for validation, but any change is silently ignored."},{"name":"is_required","type":"boolean","required":true,"description":"REQUIRED \u2014 omitting it is a 400."},{"name":"entity_type","type":"string"},{"name":"place_holder_text","type":"string"},{"name":"default_value","type":"string","description":"Only applied when `field_type` is `boolean`."}],"intent":"Update a field definition's label, requiredness or placeholder (POST to /update).","guidance":["Full replace: field_name + field_type + is_required all required","field_type changes are silently ignored","field_name, field_type and is_required must all be present","Omitting is_required is 400 \"Invalid Params\"","omitting field_name is a 500","field_name IS editable here"]},{"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","description":"Updated name of the dataset.","json_path":"/dataset/name"},{"name":"variables","type":"array","description":"Updated list of dataset variables/columns (max 40).","fields":[{"name":"name","type":"string","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/variables"},{"name":"rows","type":"array","description":"Updated list of dataset rows (max 100).","fields":[{"name":"row_number","type":"integer","required":true},{"name":"row_data","type":"array","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/rows"}],"intent":"Update a dataset.","guidance":["Update an existing dataset by its UUID with new variables and rows"],"returns":["name","created_at","rows_count","uuid"]},{"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 for the session.","json_path":"/exploratory_session/title"},{"name":"description","type":"string","description":"Updated description.","json_path":"/exploratory_session/description"},{"name":"charter","type":"string","description":"Updated testing charter.","json_path":"/exploratory_session/charter"},{"name":"timebox_duration","type":"integer","example":90,"description":"Updated planned duration in minutes.","json_path":"/exploratory_session/timebox_duration"},{"name":"duration","type":"integer","example":45,"description":"Tracked duration in minutes.","json_path":"/exploratory_session/duration"},{"name":"actual_duration","type":"integer","example":50,"description":"Final actual duration in minutes.","json_path":"/exploratory_session/actual_duration"},{"name":"folder_id","type":"integer","example":789,"description":"Move the session to a different folder.","json_path":"/exploratory_session/folder_id"},{"name":"owner","type":"integer","example":55,"description":"TCM user ID of the new owner / assignee.","json_path":"/exploratory_session/owner"},{"name":"assignee","type":"integer","example":55,"description":"Alias for `owner`.","json_path":"/exploratory_session/assignee"},{"name":"tags","type":"array","example":["smoke","payment"],"description":"Replace the session's tags with this list.","json_path":"/exploratory_session/tags"},{"name":"configurations","type":"array","example":[2,4],"description":"Replace the session's configuration IDs.","json_path":"/exploratory_session/configurations"},{"name":"attachments","type":"array","description":"Updated set of blob / media attachment IDs.","json_path":"/exploratory_session/attachments"},{"name":"issues","type":"array","description":"Replace the linked issues for this session.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/exploratory_session/issues"}],"intent":"Update an exploratory session","guidance":["edit a session (title, charter, timebox_duration, duration, owner, tags, ...)","Updates one or more fields of an exploratory session","Time fields (timebox_duration, duration, actual_duration) must be non-negative integers not exceeding 2147483647"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"]},{"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":"Updated rich-text HTML content of the log entry.","json_path":"/session_log/content"},{"name":"status","type":"string","values":["pass","fail","bug","retest","block","note"],"example":"fail","description":"Updated test result status.","json_path":"/session_log/status"},{"name":"elapsed_time","type":"integer","example":180,"description":"Updated elapsed time in seconds.","json_path":"/session_log/elapsed_time"},{"name":"defects","type":"array","description":"Replace the linked defects for this log entry.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/session_log/defects"}],"intent":"Update a session log entry","guidance":["Updates an existing log entry within an exploratory session","Rich-text HTML content is sanitised before storage"],"returns":["id","status","content","created_at","elapsed_time","session_id","updated_at"]},{"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,"description":"Name of the filter"},{"name":"entity","type":"string","required":true,"values":["testcase","testplan","testrun","exploratory_session","test_plan_test_case"],"description":"Entity type the filter applies to. The server's validator accepts five values (the\nlast two were missing here); an unlisted value returns 400 \"Invalid JSON data\"."},{"name":"is_project_level","type":"boolean","description":"Whether this filter is available at project level"},{"name":"filters","type":"object","description":"Filter criteria object containing the actual filter conditions"}],"intent":"Update a filter","guidance":["Update an existing saved filter with new configuration or filter criteria"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"]},{"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"},{"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 these are ids, not names).","json_path":"/test_result/status_id"},{"name":"description","type":"string","description":"Rich text description of the test result.","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's 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":"Update the latest test result","guidance":["update the LATEST result for a case in a run (partial, same body shape)","Update the most recent test result for a specific test case within a test run in a project","Optionally filter by configuration_id or mapping_id"],"returns":["id","name","byte_size","checksum","content_type","download_url","key","product_id","size","url"]},{"method":"POST","path":"/api/v1/projects/{project_id}/settings","mode":"write","entity":"project","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Update project settings","guidance":["Accepts an object with setting keys as properties and boolean values"]},{"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","example":10000},{"name":"report_type","type":"string","required":true,"values":["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","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_statu"},{"name":"title","type":"string","example":"Updated Title"},{"name":"description","type":"string","example":""},{"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 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`"},{"name":"report_creation_mode","type":"string","required":true,"values":["custom_report","system_generated"],"example":"custom_report"},{"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"},{"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":"Integer run ids \u2014 pairs with report_timeframe=specific_test_runs.","json_path":"/report_filters/test_runs"},{"name":"test_plans","type":"array","description":"Integer plan ids \u2014 pairs with report_timeframe=specific_test_plans.","json_path":"/report_filters/test_plans"},{"name":"test_cases","type":"string","description":"Case selection \u2014 pairs with report_timeframe=specific_test_cases. FOLDER-KEYED\n(see SelectionData), never a flat id list: the server resolves the selection\nthrough its folder map, so any other shape yields zero cases and saves an\nUNSCOPED, project-wide report at 200.\n\nSend all three keys. Folder ids are STRING keys; test-case ids are INTEGERS\n(the numeric `id`, not the TC-NNN identifier) \u2014 take bo","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":"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.","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 } }.","json_path":"/report_filters/test_plan_selection"},{"name":"builds","type":"array","json_path":"/report_filters/builds"},{"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\n`Test Run Summary`, `Test Run Detailed Report` and `Test Case Activity` \u2014 on\nthe other six report types the server never reads it and drops it silently.\nPersisted (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`."},{"name":"users","type":"array","json_path":"/mail_to/users"},{"name":"external_mails","type":"array","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\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"},{"name":"time","type":"string","example":"08:00","description":"24-hour HH:MM, UTC.","json_path":"/frequency_details/time"},{"name":"day","type":"string","example":"monday","description":"Required for weekly (lowercase day name) and monthly (integer 1-28).\nOmit for daily.","json_path":"/frequency_details/day"}],"intent":"Update a scheduled report","guidance":["FULL REPLACE, not a delta: omitted fields are nulled and dropping frequency cancels the schedule","Update the configuration of an existing scheduled report for a project"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"]},{"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":"Title of the shared step"},{"name":"folder_id","type":"integer","description":"ID of the shared-field folder to move the shared step into. Null moves it to the root (Unassigned)."},{"name":"shared_step_details","type":"array","required":true,"fields":[{"name":"id","type":"integer"},{"name":"step","type":"string"},{"name":"result","type":"string"},{"name":"order","type":"integer"},{"name":"test_data","type":"string"}]}],"intent":"Update a shared step","guidance":["title and shared_step_details are BOTH required, and the details array REPLACES the existing steps","Updates an existing shared step in a project"],"returns":["id","title"]},{"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"},{"name":"description","type":"string","json_path":"/test_plan/description"},{"name":"start_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/start_date"},{"name":"end_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/end_date"},{"name":"plan_status","type":"string","description":"Plan status. The list filter accepts new / started / completed.","json_path":"/test_plan/plan_status"},{"name":"owner","type":"integer","description":"Owner user id \u2014 resolve via the project users read first.","json_path":"/test_plan/owner"},{"name":"parent_plan_id","type":"integer","description":"Parent plan's INTEGER id. Set it to create (or re-parent into) a SUB-plan \u2014 the\nresult carries an STP-NNN identifier. Null/omitted means a top-level plan.","json_path":"/test_plan/parent_plan_id"},{"name":"tags","type":"array","json_path":"/test_plan/tags"},{"name":"reviewers","type":"array","description":"Reviewer user ids.","json_path":"/test_plan/reviewers"},{"name":"attachments","type":"array","description":"Attachment ids already uploaded via the attachments surface.","json_path":"/test_plan/attachments"},{"name":"custom_fields","type":"object","json_path":"/test_plan/custom_fields"},{"name":"issues","type":"array","description":"Linked tracker issues. Structured \u2014 NOT a flat array of keys.","fields":[{"name":"issue_id","type":"string"},{"name":"issue_type","type":"string"},{"name":"metadata","type":"object"}],"json_path":"/test_plan/issues"},{"name":"test_runs","type":"string","description":"The plan's linked test runs. Accepts EITHER an array of test-run integer ids, OR a\nbulk-selection object for \"every run matching this filter\". On update this REPLACES\nthe existing link set rather than appending.","json_path":"/test_plan/test_runs"}],"intent":"Update a test plan (or sub-plan)","guidance":["Update a plan by its INTEGER id","Takes the same body as create","so it also works on a sub-plan by its own id","clearing it promotes a sub-plan to a top-level plan","do that only when the user actually asked to move it in the hierarchy","Send only the fields you intend to change"]},{"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 AI-generated or AI-processed attachments","guidance":["upload a file as AI CONTEXT (restricted MIME types, smaller size cap) to seed test-case content","Files are stored in S3 with pre-signed URLs","File Storage: Files uploaded to S3 with pre-signed URLs (expires in ~26 minutes)"],"returns":["id","name","content_type","download_url","size","url"]},{"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 generic attachments to a project from rich text editor or other sources","guidance":["UPLOAD file blob(s) (multipart)","Uploads one or more files as generic attachments to a project","Files are stored in S3 and pre-signed URLs are returned for both viewing and downloading"],"returns":["id","name","content_type","download_url","size","url"]},{"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":"Upload one or more attachments to a test case","guidance":["Upload one or more attachments to a specific test case within a folder in a project"],"returns":["id","byte_size","content_type","filename"]},{"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 whose per-test-case / per-run / per-day breakdown is requested."},{"name":"p","type":"integer","example":1,"description":"Page number for paginated drill-down rows."},{"name":"per_page","type":"integer","example":50,"description":"Number of rows per page."}],"intent":"Fetch the User Workload drill-down for a single user","guidance":["User-Workload per-user execution breakdown","Only valid on a report whose type is User Workload Report","every other type returns 400 \"Drill-down is only available for User Workload Reports\""],"shape":"discovered","max_page_size":100,"paginated":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},"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)","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_v1"]},"attachment":{"entity":"attachment","title":"Attachments","aliases":["attachment","file","attachments","blob"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"attached to"},{"entity":"result","via":"attached to"}],"capabilities":["delete_test_case_attachment","get_test_case_attachments_v1","upload_a_i_attachments","upload_generic_attachments","upload_test_case_attachments_v1"]},"configuration":{"entity":"configuration","title":"Configurations","aliases":["config","configuration","environment","browser","os","device"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_run","via":"case status tracked against"}],"capabilities":["create_configuration_v1","get_configurations_v1"]},"custom_field":{"entity":"custom_field","title":"Custom fields & system fields","aliases":["custom field","custom fields","field","system field"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"extends"},{"entity":"result","via":"extends"}],"capabilities":["create_custom_field_dataset_option_v2","create_custom_field_dataset_v2","create_custom_field_definition_v2","delete_custom_field_dataset_option_v2","delete_custom_field_dataset_v2","delete_custom_field_definition_v2","get_custom_field_dataset_v2","get_custom_field_definition_v2","get_custom_field_values_v1","get_project_id_custom_fields_v2_v1","get_test_results_custom_fields_v1","list_custom_field_dataset_options_v2","list_workspace_fields_v2","update_custom_field_dataset_option_v2","update_custom_field_dataset_project_mapping_v2","update_custom_field_definition_v2"]},"dataset":{"entity":"dataset","title":"Dataset","aliases":["datasets","data-driven-data","dds"],"id_convention":"uuid","parents":["project"],"relations":[{"entity":"test_case","via":"drives"},{"entity":"variable","via":"has columns"}],"capabilities":["create_dataset","delete_dataset","get_dataset","get_dataset_linked_test_cases","get_dataset_variables","import_dataset_c_s_v","list_datasets","update_dataset"]},"duplicate":{"entity":"duplicate","title":"Duplicate recommendation (AI dedupe)","aliases":["duplicate","duplicates","dedupe","deduplication","duplicate recommendation","ai duplicates","redundant test cases"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"links the pair it believes are duplicates"}],"capabilities":["discard_duplicate_v1","get_dedupe_status_v1","get_duplicate_source_test_case_v1","get_duplicate_v1","list_duplicates_v1","resolve_duplicate_v1","search_duplicates_v1"]},"exploratory_session":{"entity":"exploratory_session","title":"Exploratory session","aliases":["session","exploratory","et-session"],"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_v1","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"]},"filter":{"entity":"filter","title":"Saved filter view","aliases":["filter","filters","saved view","saved 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"]},"folder":{"entity":"folder","title":"Folder","aliases":["dir","directory"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"folder","via":"nests under"},{"entity":"test_case","via":"organises"}],"capabilities":["copy_folder","create_bulk_test_cases_v1","create_root_folder_v1","create_sub_folder_v1","delete_folder_and_contents","get_folder_remove_summary","get_folder_tree_v1","get_root_folders_v1","list_folder_contents","move_folder_v1","rename_folder_v1","reorder_folders","undo_remove_folder"]},"project":{"entity":"project","title":"Project","aliases":["pr","workspace"],"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_v1","export_dashboard_analytics","get_active_test_runs_info_v1","get_automation_stats_v1","get_closed_test_runs_info_v1","get_closed_test_runs_split_v1","get_entity_filter_details_v1","get_issues_count_info_v1","get_project_by_integer_id_v1","get_project_settings","get_project_users_v2_v1","get_projects_basic_v1","get_projects_minify_v1","get_test_case_count_trend_v1","get_test_case_type_split_v1","list_projects_v1","search_project_entities","update_project_settings"]},"report":{"entity":"report","title":"Report","aliases":["reports","scheduled-report","schedule","analytics-report"],"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","delete_report_schedule","download_report","get_exploratory_session_summary_v1","get_report_attachment_url","get_report_detail","get_report_section_v1","get_report_summary_by_type","get_selected_report_testcases","list_schedules","send_report_email_now_v1","update_report","user_workload_drilldown"]},"result":{"entity":"result","title":"Result","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_v1","create_step_result_v1","create_test_result_for_test_case","delete_test_result_v1","get_test_results_for_test_case","update_latest_test_result_for_test_case"]},"shared_step":{"entity":"shared_step","title":"Shared step","aliases":["shared step","shared steps","reusable step","step template"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"reused by"}],"capabilities":["create_shared_step_v1","delete_shared_step_v1","get_shared_steps_by_i_d_v1","get_shared_steps_v1","update_shared_step_v1"]},"tag":{"entity":"tag","title":"Tag","aliases":["tags","label","labels"],"id_convention":"name","parents":["project"],"relations":[{"entity":"test_case","via":"labels"},{"entity":"test_run","via":"labels"},{"entity":"test_plan","via":"labels"}],"capabilities":["bulk_edit_test_cases_v2","get_test_case_tags","list_test_case_tags_v1","merge_tags_v1","search_group_tags","search_test_case_tags"]},"test_case":{"entity":"test_case","title":"Test case","aliases":["tc","case","test case"],"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":["bulk_archive_test_cases_by_project","bulk_copy_test_cases","bulk_delete_test_cases","bulk_edit_test_cases","bulk_edit_test_cases_in_test_run","bulk_move_test_cases","bulk_retrieve_test_cases","create_test_case_v1","create_test_cases","edit_test_case_partial_v1","edit_test_case_v1","get_archived_test_cases","get_system_field_values_v1","get_test_case_by_integer_id_v1","get_test_case_detail","get_test_cases_v1","list_folder_test_cases_v1","reorder_test_cases_by_folder_v1","search_project_entities_v2","search_test_cases_by_folder_v1"]},"test_plan":{"entity":"test_plan","title":"Test plan (and sub-plan)","aliases":["tp","plan","milestone","sub test plan","subplan","stp"],"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_v1","bulk_retrieve_test_plans_v1","clone_test_plan_v1","count_test_plan_test_runs_v1","create_test_plan_v1","delete_test_plan_v1","get_archived_test_plan_count_v1","get_test_plan_by_integer_id_v1","get_test_plan_execution_trend_v1","get_test_plan_linked_sessions_chart_data_v1","get_test_plan_test_runs_v1","list_archived_test_plans_v1","list_test_plans_v1","update_test_plan_v1"]},"test_run":{"entity":"test_run","title":"Test run","aliases":["tr","run","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":["assign_test_run_owner","bulk_assign_test_cases_to_test_run","clone_test_run","close_test_run_v1","count_run_selection_v1","create_test_run_v1","delete_v1_test_run","edit_test_run","get_closed_test_runs","get_test_cases_for_v1_test_run","get_test_run_by_integer_id_v1","get_test_run_cases_v1","get_test_runs_form_fields_v1","get_test_runs_selection","get_test_runs_v1"]},"user":{"entity":"user","title":"User","aliases":["users","member","assignee","owner"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"project","via":"member of"},{"entity":"test_run","via":"assigned to"},{"entity":"test_case","via":"owns"}],"capabilities":["get_group_users_v2_v1","search_project_users"]},"version":{"entity":"version","title":"Test case version","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":["get_test_case_histories_v1","get_test_case_history_diff_v1","get_test_case_history_v1","restore_history_v1"]}}}}} \ No newline at end of file diff --git a/tests/tools/capabilityRegistry.test.ts b/tests/tools/capabilityRegistry.test.ts index 3afa6af..8550256 100644 --- a/tests/tools/capabilityRegistry.test.ts +++ b/tests/tools/capabilityRegistry.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it } from "vitest"; import { bind, coerce } from "../../src/tools/capability-registry/bind.js"; -import { - discoveredFields, isEnvelopeField, itemsOf, projectRow, totalOf, -} from "../../src/tools/capability-registry/envelope.js"; import { authHeaders } from "../../src/tools/capability-registry/egress.js"; import { CapabilityRegistry, InvocationError, IndexError, @@ -132,53 +129,6 @@ describe("binding", () => { }); }); -describe("finding rows in a response", () => { - it("finds rows by shape, whatever the envelope calls them", () => { - // tm uses 30 distinct row-key names; a hardcoded list missed 24 of them. - expect(itemsOf({ success: true, path_folders: [{ id: 1 }] })).toEqual([{ id: 1 }]); - }); - - it("treats a single wrapped record as one row", () => { - // Without this every stats/summary/detail read came back ok:true, count:0, items:[]. - expect(itemsOf({ success: true, project: { id: 4 } })).toEqual([{ id: 4 }]); - expect(itemsOf({ id: 9, name: "flat" })).toEqual([{ id: 9, name: "flat" }]); - }); - - it("keeps an empty array distinguishable from a shape with no rows", () => { - expect(itemsOf({ success: true, test_cases: [] })).toEqual([]); - }); - - it("reads the total out of the envelope", () => { - expect(totalOf({ info: { count: 880 } })).toBe(880); - }); - - it("knows an envelope field from a row field", () => { - for (const name of ["total_pages", "has_more", "is_empty", "count", "success"]) { - expect(isEnvelopeField(name)).toBe(true); - } - expect(isEnvelopeField("identifier")).toBe(false); - }); -}); - -describe("projection", () => { - it("keeps only declared fields", () => { - expect(projectRow({ id: 1, secret_note: "x" }, ["id"], false)).toEqual({ id: 1 }); - }); - - it("emits nothing when nothing is declared and discovery is off", () => { - expect(projectRow({ id: 1 }, [], false)).toEqual({}); - }); - - it("discovers scalars but never expands an object or a sensitive name", () => { - // `assignee` expands to email/full_name/browserstack_user_id; field_values carry - // signed URLs. Both are objects, so neither can travel this path. - expect(discoveredFields({ - id: 7, identifier: "TP-3", assignee: { email: "a@b.c" }, tags: ["x"], - api_token: "t", user_email: "a@b.c", user_id: 4, - })).toEqual({ id: 7, identifier: "TP-3" }); - }); -}); - describe("search", () => { it("does not treat verbs as stopwords", () => { expect(terms("list the test cases")).toEqual(["list", "test", "cases"]); @@ -220,75 +170,71 @@ describe("auth", () => { }); }); -describe("invoke", () => { +describe("invoke — one request, response returned untouched", () => { const credentials = { username: "u", accessKey: "k" }; - it("pages to completion at the declared ceiling and projects the rows", async () => { - const seen: { query: Record; headers: Record }[] = []; - const transport = async ( - _m: string, _u: string, headers: Record, query: Record, - ) => { - seen.push({ query, headers }); - const page = Number(query.p || 1); - const start = (page - 1) * 300; - const rows = Array.from({ length: Math.max(0, Math.min(300, 880 - start)) }, (_v, i) => ({ - id: start + i, identifier: `TC-${start + i}`, title: "t", leaked: "no", - })); - return { status: 200, body: { test_cases: rows, info: { count: 880 } } }; + it("makes exactly ONE request and hands back status and body", async () => { + let calls = 0; + const transport = async () => { + calls += 1; + return { + status: 200, + body: { test_cases: [{ id: 1, leaked: "kept" }], info: { count: 880, next: null } }, + }; }; const result = await invoke( LIST_CASES, { path_params: { project_id: 1, folder_id: 2 } }, - "https://tm.example", credentials, transport, {}, - // The ceiling is NOT on the capability: it is a resolver control, so it travels in the - // artifact's sibling `paging` map and is passed in here. - { page: "p", size: "count", max: 300 }, + "https://tm.example", credentials, transport, ); + expect(calls).toBe(1); // paging is the caller's now expect(result.ok).toBe(true); - expect(result.count).toBe(880); - expect(result.requests_made).toBe(3); // not 30 at the product's default - expect(seen[0].query.count).toBe(300); - expect(seen[0].headers["Api-Token"]).toBe("u:k"); - expect(Object.keys(result.items[0])).toEqual(["id", "identifier", "title"]); + expect(result.completed).toBe(true); // the envelope says next: null + // No extraction, no counting, no projection — the body as sent, `leaked` included. + expect(result.http_response).toEqual({ + status: 200, + body: { test_cases: [{ id: 1, leaked: "kept" }], info: { count: 880, next: null } }, + }); }); - it("reports drift rather than an empty answer", async () => { - const transport = async () => ({ status: 200, body: { rows: [{ unrelated: 1 }] } }); + it("says the answer is incomplete when the envelope declares another page", async () => { + const transport = async () => ({ + status: 200, body: { test_cases: [{ id: 1 }], info: { count: 880, next: 2 } }, + }); const result = await invoke( LIST_CASES, { path_params: { project_id: 1, folder_id: 2 } }, - "https://tm.example", credentials, transport, {}, { page: "p", size: "count", max: 300 }, + "https://tm.example", credentials, transport, ); - expect(result.ok).toBe(false); - expect(result.error).toMatch(/capability_returns_drift/); + expect(result.ok).toBe(true); + expect(result.completed).toBe(false); }); - it("surfaces a non-2xx as a failed call, not as empty rows", async () => { - const transport = async () => ({ status: 401, body: { error: "Unauthorized" } }); + it("mirrors a non-2xx and lets the product's own body explain it", async () => { + const transport = async () => ({ + status: 422, + body: { success: false, error: "Drill-down is only available for User Workload Reports" }, + }); const result = await invoke( LIST_CASES, { path_params: { project_id: 1, folder_id: 2 } }, "https://tm.example", credentials, transport, ); expect(result.ok).toBe(false); - expect(result.error).toMatch(/401/); + expect(result.completed).toBe(false); + expect(result.http_response.status).toBe(422); + expect((result.http_response.body as Record).error) + .toMatch(/User Workload Reports/); }); -}); - -describe("paging controls stay out of the published surface", () => { - it("is read from the artifact's sibling map, not from the capability", () => { - const registry = new CapabilityRegistry({ - ...INDEX, - products: { - tm: { - ...INDEX.products.tm, - paging: { [`GET ${LIST_CASES.path}`]: { page: "p", size: "count", max: 300 } }, - }, - }, + it("reports an unreachable product as status 0", async () => { + const transport = async () => ({ + status: 0, body: null, error: "the product could not be reached", }); - expect(registry.pagingFor("tm", LIST_CASES)).toEqual({ page: "p", size: "count", max: 300 }); - // an endpoint that does not page gets an empty rule rather than a guess - expect(registry.pagingFor("tm", CREATE_FOLDER)).toEqual({}); - // and none of it is visible to a caller searching - const hit = searchCapabilities(registry.index.products, "test cases").capabilities[0]; - expect(JSON.stringify(hit)).not.toContain('"page"'); + const result = await invoke( + LIST_CASES, { path_params: { project_id: 1, folder_id: 2 } }, + "https://tm.example", credentials, transport, + ); + expect(result.ok).toBe(false); + expect(result.http_response.status).toBe(0); + expect(result.http_response.error).toMatch(/could not be reached/); }); }); + diff --git a/tests/tools/capabilityRegistryE2E.test.ts b/tests/tools/capabilityRegistryE2E.test.ts index 3d3cfaf..680fc48 100644 --- a/tests/tools/capabilityRegistryE2E.test.ts +++ b/tests/tools/capabilityRegistryE2E.test.ts @@ -63,7 +63,7 @@ describe("capability registry, end to end through the server factory", () => { expect(payload.capabilities[0].path.startsWith("/api/")).toBe(true); }); - it("invokes a real endpoint: forwards Api-Token, pages, and projects the rows", async () => { + it("invokes a real endpoint: forwards Api-Token and returns the response untouched", async () => { process.env.CAPABILITY_REGISTRY_BASE_URL_TM = "https://tm.example"; const calls: { url: string; headers: Record }[] = []; vi.stubGlobal("fetch", async (url: string, init: any) => { @@ -88,11 +88,11 @@ describe("capability registry, end to end through the server factory", () => { expect(calls[0].headers["Api-Token"]).toBe("ing_Xx:SECRET"); expect(calls[0].headers["request-source"]).toBe("ai-chatbot"); expect(calls[0].url.startsWith("https://tm.example/api/v1/projects/basic")).toBe(true); - // the page-size ceiling the operation declares, not the product's default - expect(calls[0].url).toContain("count=300"); - // projected to the declared returns: the extra field never reaches the caller - expect(payload.items[0].leaked).toBeUndefined(); - expect(payload.items[0].id).toBe(1); + // ONE request, and the body exactly as the product sent it + expect(calls).toHaveLength(1); + expect(payload.http_response.status).toBe(200); + expect(payload.http_response.body.projects[0]) + .toEqual({ id: 1, name: "P", description: "d", leaked: "no" }); }); it("refuses a destructive endpoint without calling the product", async () => { From 2f3d19425b57eee1deb8434cf82cc4c27c25960c Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Mon, 24 Aug 2026 15:22:20 +0530 Subject: [PATCH 06/31] Ask BrowserStack AI, and relay its permission asks to the human MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Approach 1 shipped read-only because there was no way to ask a human mid-run: the old surface refused every write and listed what it would have needed. MCP elicitation is that missing piece, so this adds one tool that hands a plain-language task to BrowserStack's agent and asks the person sitting in front of the calling client whenever the agent reaches a step that changes data. Transport is a caller-supplied callback URL, which matters more than it looks. Because the agent makes the OUTBOUND request, the decision comes back on the same connection to the same pod, so the affinity problem that would otherwise force either a single replica or a Redis nudge does not arise here at all. The listener is per tool call, on loopback, on a port the OS picks, behind a fresh 256-bit bearer compared in constant time and checked before the body is even read. The threat model is local: every other process on the developer's machine can reach that port, and a human trained to approve prompts is the exploit, so a caller that cannot present the run's token gets a 401 and no prompt appears. Everything ambiguous denies. Only accept plus confirm: true is an allow. A cancel is a deny with reason "cancelled" rather than "declined" because a headless client with nobody at the terminal returns exactly that — which is what stops an unattended run from approving its own writes, and why an elicitation is never retried. A client that cannot elicit is not a failure case. permission_relay is omitted entirely rather than sent empty, its absence selects the read-only gate, and the result says why the write was refused instead of leaving the caller with an unexplained failure. Nothing depends on sampling, which Claude Code does not declare. The result carries the approval trail and applied_before_stop, so a caller can tell "nothing happened" from "some steps applied, then stopped" and does not retry a half-applied task. --- src/server-factory.ts | 5 + src/tools/ask-browserstack/callback.ts | 210 +++++++++++++ src/tools/ask-browserstack/config.ts | 78 +++++ src/tools/ask-browserstack/egress.ts | 69 +++++ src/tools/ask-browserstack/register.ts | 284 ++++++++++++++++++ src/tools/ask-browserstack/relay.ts | 161 ++++++++++ src/tools/ask-browserstack/types.ts | 107 +++++++ tests/tools/askBrowserstack.test.ts | 291 ++++++++++++++++++ tests/tools/askBrowserstackE2E.test.ts | 393 +++++++++++++++++++++++++ 9 files changed, 1598 insertions(+) create mode 100644 src/tools/ask-browserstack/callback.ts create mode 100644 src/tools/ask-browserstack/config.ts create mode 100644 src/tools/ask-browserstack/egress.ts create mode 100644 src/tools/ask-browserstack/register.ts create mode 100644 src/tools/ask-browserstack/relay.ts create mode 100644 src/tools/ask-browserstack/types.ts create mode 100644 tests/tools/askBrowserstack.test.ts create mode 100644 tests/tools/askBrowserstackE2E.test.ts diff --git a/src/server-factory.ts b/src/server-factory.ts index b469021..0633434 100644 --- a/src/server-factory.ts +++ b/src/server-factory.ts @@ -21,6 +21,7 @@ import { setupOnInitialized } from "./oninitialized.js"; import { BrowserStackConfig } from "./lib/types.js"; import addRCATools from "./tools/rca-agent.js"; import addCapabilityRegistryTools from "./tools/capability-registry/register.js"; +import addAskBrowserstackAITool from "./tools/ask-browserstack/register.js"; /** * Wrapper class for BrowserStack MCP Server @@ -66,6 +67,10 @@ export class BrowserStackMcpServer { // nothing (and logs why) when the artifact is absent, so a packaging problem cannot // take the other products' tools down with it. addCapabilityRegistryTools, + // Hands a plain-language task to BrowserStack's agent and relays its mid-run + // permission asks back to this client, so a write can be confirmed by the human + // sitting in front of it rather than refused for want of anyone to ask. + addAskBrowserstackAITool, ]; toolAdders.forEach((adder) => { diff --git a/src/tools/ask-browserstack/callback.ts b/src/tools/ask-browserstack/callback.ts new file mode 100644 index 0000000..fc557b5 --- /dev/null +++ b/src/tools/ask-browserstack/callback.ts @@ -0,0 +1,210 @@ +/** + * The loopback listener Atlas calls back on (CONTRACT §1-2). + * + * Transport is A2: Atlas makes the OUTBOUND request and blocks on its response, which is + * what dissolves the affinity problem that dominates PLAN.md — the decision returns on the + * same connection to the same pod, so there is no Redis nudge and no single-replica limit. + * + * THE THREAT MODEL IS LOCAL. This binds a port on the developer's own machine, so every + * other process on that machine can reach it. A stray one must never be able to make a + * confirmation prompt appear, because a human trained to approve prompts is the exploit. + * Hence: a fresh 256-bit bearer per run, compared in constant time, checked BEFORE the body + * is even parsed, and 401 with no elicitation attempted on any mismatch. + * + * Everything ambiguous is a deny. A body we cannot parse, a `perm_id` that is not Atlas's + * shape, a blank description, a handler that throws — none of them produce an approval, and + * each answers in a way CONTRACT's fail-closed rule already maps to deny on Atlas's side. + */ + +import { randomBytes, timingSafeEqual } from "node:crypto"; +import { createServer, IncomingMessage, Server, ServerResponse } from "node:http"; +import { AddressInfo, Socket } from "node:net"; + +import logger from "../../logger.js"; +import { PermissionAsk, PermissionDecision } from "./types.js"; + +/** The path half of `callback_url`. The port half is whatever the OS hands us. */ +export const CALLBACK_PATH = "/atlas-permission"; + +/** Atlas's `f"perm-{uuid.uuid4().hex}"`, and nothing else. */ +export const PERM_ID_PATTERN = /^perm-[0-9a-f]{32}$/; + +/** An ask is four short fields. Anything larger is not one. */ +const MAX_BODY_BYTES = 64 * 1024; + +export type AskHandler = (ask: PermissionAsk) => Promise; + +export interface CallbackListener { + /** Derived from the port actually bound, never a hardcoded one. */ + url: string; + /** Minted for this run alone. */ + token: string; + close(): Promise; +} + +/** Constant-time, and length-safe: `timingSafeEqual` throws on a length mismatch. */ +function tokenMatches(presented: string, expected: string): boolean { + const a = Buffer.from(presented, "utf8"); + const b = Buffer.from(expected, "utf8"); + // The token's length is fixed and public, so leaking it costs nothing. + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} + +function bearer(header: string | undefined): string { + if (!header) return ""; + const match = /^bearer[ \t]+(.+)$/i.exec(header.trim()); + return match ? match[1].trim() : ""; +} + +function respond(response: ServerResponse, status: number, payload: unknown): void { + const text = JSON.stringify(payload); + response.writeHead(status, { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(text), + }); + response.end(text); +} + +async function readBody(request: IncomingMessage): Promise { + const chunks: Buffer[] = []; + let size = 0; + for await (const chunk of request) { + const buffer = chunk as Buffer; + size += buffer.length; + if (size > MAX_BODY_BYTES) throw new Error("body too large"); + chunks.push(buffer); + } + return Buffer.concat(chunks).toString("utf8"); +} + +/** + * Read an ask out of a parsed body, or return null. + * + * A blank description is rejected rather than relayed: the description IS the whole of what + * the human is shown, so an empty one is a prompt asking a person to approve nothing. + */ +export function parseAsk(body: unknown): PermissionAsk | null { + if (typeof body !== "object" || body === null || Array.isArray(body)) return null; + const record = body as Record; + const permId = record.perm_id; + const description = record.description; + if (typeof permId !== "string" || !PERM_ID_PATTERN.test(permId)) return null; + if (typeof description !== "string" || !description.trim()) return null; + return { + perm_id: permId, + product: typeof record.product === "string" ? record.product : "", + mode: typeof record.mode === "string" ? record.mode : "", + description, + }; +} + +/** + * Start one listener for one tool call. + * + * Per call, not per process: two concurrent calls get two ports and two tokens, so a + * callback for one run can never be answered by the other's elicitation. Port 0 lets the OS + * pick, which is also why the URL is read back off the bound address. + */ +export async function startCallbackListener( + onAsk: AskHandler, +): Promise { + const token = randomBytes(32).toString("hex"); + const sockets = new Set(); + + const server: Server = createServer((request, response) => { + void handle(request, response); + }); + + async function handle( + request: IncomingMessage, + response: ServerResponse, + ): Promise { + try { + const path = (request.url || "").split("?")[0]; + if (request.method !== "POST" || path !== CALLBACK_PATH) { + request.resume(); + respond(response, 404, { error: "not found" }); + return; + } + + // AUTH FIRST, before the body is read or parsed. A caller that cannot present the + // token gets no elicitation, no prompt, and nothing back that describes the run. + if (!tokenMatches(bearer(request.headers.authorization), token)) { + request.resume(); + logger.warn( + "askBrowserstackAI: rejected a permission callback with a bad or missing token", + ); + respond(response, 401, { error: "unauthorized" }); + return; + } + + let parsed: unknown; + try { + parsed = JSON.parse(await readBody(request)); + } catch { + // No usable `perm_id` to echo, so there is no valid 200 to send. A non-200 is a + // deny on Atlas's side, which is the right answer to a body we cannot read. + respond(response, 400, { error: "malformed body" }); + return; + } + + const ask = parseAsk(parsed); + if (!ask) { + respond(response, 400, { error: "malformed permission ask" }); + return; + } + + const decision = await onAsk(ask); + respond(response, 200, { + // Echoed exactly. Atlas treats a mismatch as a deny, and so should it. + perm_id: ask.perm_id, + decision: decision.decision, + reason: decision.reason, + }); + } catch (error) { + logger.error( + "askBrowserstackAI: permission callback failed: %s", + error instanceof Error ? error.message : String(error), + ); + // Fail closed. Atlas maps a non-200 to a deny and records `error_relay`. + if (!response.headersSent) respond(response, 500, { error: "relay failed" }); + else response.end(); + } + } + + server.on("connection", (socket) => { + sockets.add(socket); + socket.on("close", () => sockets.delete(socket)); + }); + + // A callback is held open for as long as the human takes to answer. Node's default + // 300s `requestTimeout` would cut that off at almost exactly the elicitation budget, so + // the request timeout is disabled and the elicitation's own 270s is the only clock. + server.requestTimeout = 0; + server.headersTimeout = 60_000; + + await new Promise((resolve, reject) => { + server.once("error", reject); + // LOOPBACK ONLY. Binding 0.0.0.0 would publish an approval prompt to the network. + server.listen(0, "127.0.0.1", () => { + server.removeListener("error", reject); + resolve(); + }); + }); + + const address = server.address() as AddressInfo; + return { + url: `http://127.0.0.1:${address.port}${CALLBACK_PATH}`, + token, + close(): Promise { + return new Promise((resolve) => { + // Destroy first: `close()` alone waits out idle keep-alive connections, and this + // runs in a `finally` that must not be able to hang the tool call. + for (const socket of sockets) socket.destroy(); + sockets.clear(); + server.close(() => resolve()); + }); + }, + }; +} diff --git a/src/tools/ask-browserstack/config.ts b/src/tools/ask-browserstack/config.ts new file mode 100644 index 0000000..69cc2eb --- /dev/null +++ b/src/tools/ask-browserstack/config.ts @@ -0,0 +1,78 @@ +/** + * Where Atlas lives, and the timeout ladder. + * + * No host is compiled in. A guessed host fails as a DNS error or a 404 that reads like the + * caller's problem when it is our missing configuration, and a hardcoded default would send + * a preprod deployment at production — the same reasoning as the capability registry's + * `resolveBaseUrl`, and the same precedence rungs. + */ + +/** + * CONTRACT §4 — the timeout ladder, outermost first. EACH LAYER MUST EXCEED THE ONE INSIDE + * IT, or a layer dies before the layer it is waiting on can answer: + * + * MCP client -> tool call longest, client-side, not ours + * POST /agent HTTP request 330s <- here + * Atlas gate -> callback POST 300s Atlas's `permission_relay_timeout` + * elicitInput 270s <- here + * + * 300s is the browser path's existing PERMISSION_TIMEOUT, which also auto-rejects. + */ +export const AGENT_TIMEOUT_MS = 330_000; +export const ELICITATION_TIMEOUT_MS = 270_000; + +/** Thrown for anything this tool refuses to attempt. Never carries a credential. */ +export class AskError extends Error {} + +/** Off by default is wrong for a shipped feature, but a kill switch is not. */ +export function isEnabled(): boolean { + return (process.env.ASK_BROWSERSTACK_DISABLED || "").toLowerCase() !== "true"; +} + +/** + * The environment this DEPLOYMENT points at, e.g. "preprod". + * + * `CAPABILITY_REGISTRY_ENV` is honoured as a fallback on purpose: it is the same deployment + * pointing at the same environment, and making an operator state that fact twice is how the + * two drift. + */ +export function selectedEnvironment(): string { + return ( + process.env.ASK_BROWSERSTACK_ENV || + process.env.CAPABILITY_REGISTRY_ENV || + "" + ).trim(); +} + +/** + * Resolve Atlas's base URL: + * + * 1. ASK_BROWSERSTACK_ATLAS_URL explicit, environment-agnostic + * 2. ASK_BROWSERSTACK_ATLAS_URL_ this environment's host + * 3. refuse, by name + * + * Refusing rather than guessing is the point of rung 3. + */ +export function atlasBaseUrl(): string { + const explicit = process.env.ASK_BROWSERSTACK_ATLAS_URL; + if (explicit && explicit.trim()) return explicit.trim().replace(/\/$/, ""); + + const environment = selectedEnvironment(); + if (environment) { + const suffixed = + process.env[`ASK_BROWSERSTACK_ATLAS_URL_${environment.toUpperCase()}`]; + if (suffixed && suffixed.trim()) return suffixed.trim().replace(/\/$/, ""); + } + + throw new AskError( + "no host is configured for BrowserStack AI: set ASK_BROWSERSTACK_ATLAS_URL" + + (environment + ? ` or ASK_BROWSERSTACK_ATLAS_URL_${environment.toUpperCase()}` + : ""), + ); +} + +/** Resolved per call, never captured at construction. */ +export function agentUrl(): string { + return `${atlasBaseUrl()}/agent`; +} diff --git a/src/tools/ask-browserstack/egress.ts b/src/tools/ask-browserstack/egress.ts new file mode 100644 index 0000000..1b2f976 --- /dev/null +++ b/src/tools/ask-browserstack/egress.ts @@ -0,0 +1,69 @@ +/** + * The outbound `POST /agent`, behind a seam. + * + * The seam is the point: the Atlas half of this feature is being built in parallel and does + * not exist yet, so every test substitutes this rather than reaching a live service — the + * same role `RegistryDeps.transport` plays for the capability registry. + * + * Auth is the CALLER'S OWN CREDENTIALS, forwarded as `Api-Token: :`. + * Nothing is minted here; `authHeaders` is reused rather than reimplemented so the two + * surfaces cannot drift on a security-relevant header. + */ + +import { authHeaders, Credentials } from "../capability-registry/egress.js"; +import { AGENT_TIMEOUT_MS } from "./config.js"; +import { AgentRequest } from "./types.js"; + +export { authHeaders }; +export type { Credentials }; + +export interface AgentResponse { + status: number; + body: unknown; + /** Only when there was no response at all to speak for itself. */ + error?: string; +} + +export type AgentTransport = ( + url: string, + headers: Record, + body: AgentRequest, +) => Promise; + +/** + * A fetch-based transport. + * + * The 330s budget is the outer rung of CONTRACT §4's ladder: it must outlast Atlas's own + * 300s gate timeout, which must in turn outlast our 270s elicitation, or a layer dies before + * the layer it is waiting on can answer. + */ +export function fetchAgentTransport( + timeoutMs = AGENT_TIMEOUT_MS, +): AgentTransport { + return async (url, headers, body) => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { + method: "POST", + headers, + 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. + redirect: "manual", + signal: controller.signal, + }); + let parsed: unknown = null; + const contentType = response.headers.get("content-type") || ""; + if (contentType.includes("json")) { + parsed = await response.json().catch(() => null); + } + return { status: response.status, body: parsed }; + } catch { + // Upstream detail stays out of the reply; status 0 is read as a failed call. + return { status: 0, body: null, error: "BrowserStack AI could not be reached" }; + } finally { + clearTimeout(timer); + } + }; +} diff --git a/src/tools/ask-browserstack/register.ts b/src/tools/ask-browserstack/register.ts new file mode 100644 index 0000000..6436c6a --- /dev/null +++ b/src/tools/ask-browserstack/register.ts @@ -0,0 +1,284 @@ +/** + * `askBrowserstackAI` — one tool call in, one tool result out, with a human's approval + * relayed through the middle of it. + * + * The shape, and why: + * + * 1. NEGOTIATE FIRST. `getClientCapabilities()?.elicitation` is checked BEFORE Atlas is + * called, so Atlas learns whether a human is reachable before it starts rather than + * discovering it at the gate. No capability means `permission_relay` is omitted + * entirely and Atlas runs read-only — today's exact behaviour, and the path opencode + * and goose stay on. Nothing here depends on `sampling`, which Claude Code does not + * declare. + * 2. LISTEN ON LOOPBACK. Transport is A2, so Atlas calls US back; because it initiates, + * the decision returns on the same connection to the same pod and PLAN.md's affinity + * problem never arises for this stdio deployment. + * 3. ELICIT, ONCE. Atlas's `description` is the message, `{ confirm: boolean }` the + * schema, and the answer is mapped by CONTRACT §7 with no second chances. + * 4. RETURN THE TRAIL. `approvals` and `applied_before_stop` are what let a caller tell + * "nothing happened" from "some steps applied, then stopped". + * + * FAIL CLOSED THROUGHOUT. Only `accept` plus `confirm: true` is an allow. Everything else — + * a decline, a cancel, a timeout, a bad token, a body we cannot parse, a handler that throws + * — denies. + */ + +import { McpServer, RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + CallToolResult, + ElicitResult, + ErrorCode, + McpError, +} from "@modelcontextprotocol/sdk/types.js"; +import { z } from "zod"; + +import { trackMCP } from "../../lib/instrumentation.js"; +import { BrowserStackConfig } from "../../lib/types.js"; +import logger from "../../logger.js"; +import { + AskError, + ELICITATION_TIMEOUT_MS, + agentUrl, + isEnabled, +} from "./config.js"; +import { + AgentTransport, + Credentials, + authHeaders, + fetchAgentTransport, +} from "./egress.js"; +import { + CallbackListener, + startCallbackListener, +} from "./callback.js"; +import { buildResult, decide, errorResult } from "./relay.js"; +import { + AgentRequest, + ApprovalRecord, + AskResult, + PermissionAsk, + PermissionDecision, + PRODUCTS, +} from "./types.js"; + +export interface AskDeps { + /** Resolved per call: a deployment's host is configuration, not a constructor argument. */ + agentUrl: () => string; + /** + * Read per call, not captured: the remote server rebuilds config per session, so a + * captured credential would outlive the session it belongs to. + */ + credentialsFor: () => Credentials; + transport?: AgentTransport; + /** The seam the tests bind a fake listener to. */ + startListener?: typeof startCallbackListener; +} + +const DESCRIPTION = + "Ask a question or request a change in plain language about a BrowserStack product. " + + "BrowserStack's agent decides which calls to make and returns its answer plus the steps " + + "it took. Anything that would change data pauses and asks you to confirm it first, in " + + "your own client; deletes are refused outright. If your client cannot show you a prompt, " + + "the run is read-only and everything it wanted to change comes back in `needs_approval` " + + "instead. One task per call."; + +const CONFIRM_TITLE = "Approve this change"; +const CONFIRM_DESCRIPTION = + "Yes, make this change. Anything else — including dismissing this prompt — refuses it."; + +function toResult(payload: AskResult): CallToolResult { + return { + content: [{ type: "text", text: JSON.stringify(payload) }], + ...(payload.ok ? {} : { isError: true }), + }; +} + +function isTimeout(error: unknown): boolean { + return error instanceof McpError && error.code === ErrorCode.RequestTimeout; +} + +/** + * Relay one ask to the human and record what they said. + * + * The elicitation is NEVER retried. A client that timed out or errored has told us it + * cannot get an answer, and asking again only produces a second prompt for the same action. + */ +async function relayOneAsk( + server: McpServer, + ask: PermissionAsk, + approvals: ApprovalRecord[], +): Promise { + let answer: ElicitResult; + try { + answer = await server.server.elicitInput( + { + mode: "form", + // Atlas's `thought`, verbatim: product language, already free of the route, + // op_key and host that stay on its side of the boundary. + message: ask.description, + requestedSchema: { + type: "object", + properties: { + confirm: { + type: "boolean", + title: CONFIRM_TITLE, + description: CONFIRM_DESCRIPTION, + // Defaulting to false so that a client which submits the form untouched + // refuses rather than approves. + default: false, + }, + }, + required: ["confirm"], + }, + }, + // The inner rung of CONTRACT §4's ladder, strictly shorter than Atlas's 300s gate. + { timeout: ELICITATION_TIMEOUT_MS }, + ); + } catch (error) { + if (isTimeout(error)) { + approvals.push({ + description: ask.description, + decision: "deny", + reason: "timeout", + }); + return { perm_id: ask.perm_id, decision: "deny", reason: "timeout" }; + } + // An unexpected failure has no honest `reason` in CONTRACT §2's vocabulary, so it is + // not given one: rethrowing makes the callback answer 500, which Atlas's fail-closed + // rule already reads as a deny and records as `error_relay`. + approvals.push({ + description: ask.description, + decision: "deny", + reason: "error", + }); + throw error; + } + + const { decision, reason } = decide(answer); + approvals.push({ description: ask.description, decision, reason }); + return { perm_id: ask.perm_id, decision, reason }; +} + +export function addAskBrowserstackAITool( + server: McpServer, + deps: AskDeps, + config?: BrowserStackConfig, +): Record { + const transport = deps.transport || fetchAgentTransport(); + const startListener = deps.startListener || startCallbackListener; + const tools: Record = {}; + + /** Instrumentation in the house style, and never fatal to the call it wraps. */ + const track = (name: string) => { + try { + trackMCP(name, server.server.getClientVersion()!, undefined, config); + } catch { + // Telemetry must not decide whether a tool call succeeds. + } + }; + + tools.askBrowserstackAI = server.tool( + "askBrowserstackAI", + DESCRIPTION, + { + product: z + .enum(PRODUCTS) + .describe( + "Which product to work in: tm (Test Management), a11y (Accessibility), " + + "tra (Test Reporting & Analytics).", + ), + query: z + .string() + .describe("What you want, in plain language. One thing per call."), + }, + { + // It can write now, which is the whole point of the relay. Destructive operations + // stay refused, so `destructiveHint` is false for the same reason invokeEndpoint + // sets it false: consent is not a licence to delete. + readOnlyHint: false, + destructiveHint: false, + title: "Ask BrowserStack AI", + }, + async ({ product, query }): Promise => { + track("askBrowserstackAI"); + const approvals: ApprovalRecord[] = []; + // Negotiated before anything else so the failure paths below report the mode they + // would have run in. + const canElicit = Boolean( + server.server.getClientCapabilities()?.elicitation, + ); + let listener: CallbackListener | undefined; + + try { + const url = deps.agentUrl(); + const headers = authHeaders(deps.credentialsFor()); + const body: AgentRequest = { task: query, product }; + + if (canElicit) { + listener = await startListener((ask) => + relayOneAsk(server, ask, approvals), + ); + body.permission_relay = { + callback_url: listener.url, + token: listener.token, + }; + } else { + // Omitted ENTIRELY, not sent empty: its absence is what selects Atlas's + // read-only HeadlessGate. + logger.info( + "askBrowserstackAI: client declares no elicitation capability; running " + + "read-only without a permission relay", + ); + } + + return toResult(buildResult(await transport(url, headers, body), approvals, canElicit)); + } catch (error) { + const message = + error instanceof AskError || error instanceof Error + ? error.message + : String(error); + logger.error("askBrowserstackAI failed: %s", message); + return toResult(errorResult(message, approvals, canElicit)); + } finally { + // Torn down here so it cannot leak across calls or survive an error, and awaited + // so the port is released before the tool result is handed back. + if (listener) { + await listener.close().catch((error) => { + logger.warn( + "askBrowserstackAI: permission callback listener did not close cleanly: %s", + error instanceof Error ? error.message : String(error), + ); + }); + } + } + }, + ); + + return tools; +} + +/** The tool-adder the server factory calls. */ +export function addAskBrowserstackAIToolFromConfig( + server: McpServer, + config: BrowserStackConfig, +): Record { + if (!isEnabled()) { + logger.info("askBrowserstackAI disabled by ASK_BROWSERSTACK_DISABLED"); + return {}; + } + return addAskBrowserstackAITool( + server, + { + // Both resolved per call. An unconfigured host surfaces as a named error from the + // tool rather than as a missing tool, so the cause is visible to whoever hits it. + agentUrl, + credentialsFor: () => ({ + username: config["browserstack-username"], + accessKey: config["browserstack-access-key"], + }), + }, + config, + ); +} + +export default addAskBrowserstackAIToolFromConfig; diff --git a/src/tools/ask-browserstack/relay.ts b/src/tools/ask-browserstack/relay.ts new file mode 100644 index 0000000..e1dbb98 --- /dev/null +++ b/src/tools/ask-browserstack/relay.ts @@ -0,0 +1,161 @@ +/** + * The decision mapping and the result assembly — the two places where being wrong is + * expensive, kept pure so they can be tested without a server, a socket or a client. + */ + +import { ElicitResult } from "@modelcontextprotocol/sdk/types.js"; + +import { AgentResponse } from "./egress.js"; +import { + ApprovalRecord, + AskResult, + AskStatus, + ASK_STATUSES, + Decision, + DecisionReason, +} from "./types.js"; + +export const RELAY_ON_DETAIL = + "This client can prompt you, so BrowserStack asked before each change and the answers " + + "are in `approvals`."; + +export const RELAY_OFF_DETAIL = + "This client does not support MCP elicitation, so there was no way to ask you mid-run. " + + "BrowserStack ran read-only: anything that would have changed data was refused and is " + + "listed in `needs_approval`. Re-run the same request from a client that supports " + + "elicitation to be asked for confirmation instead."; + +/** + * CONTRACT §7, exactly. + * + * | accept + confirm: true | allow | "" | + * | accept + confirm: false | deny | declined | + * | decline | deny | declined | + * | cancel | deny | cancelled | + * + * `cancel` IS THE LOAD-BEARING ROW. A headless Claude Code with no human at a terminal + * returns `cancel` — measured, not assumed — so treating it as anything but a deny would + * let an unattended run self-approve, which is the one property that makes this feature + * safe to ship. It is also why the caller never retries an elicitation: a second ask cannot + * conjure a human, it can only wear one down. + * + * `confirm` is compared to the boolean `true` and nothing else. A string "true", a 1, or a + * missing field is not consent. + */ +export function decide(result: ElicitResult): { + decision: Decision; + reason: DecisionReason; +} { + if (result.action === "accept") { + return result.content?.confirm === true + ? { decision: "allow", reason: "" } + : { decision: "deny", reason: "declined" }; + } + if (result.action === "decline") return { decision: "deny", reason: "declined" }; + // `cancel`, and anything a future client sends that we do not recognise: no explicit + // answer was given, which is not an answer we may read as yes. + return { decision: "deny", reason: "cancelled" }; +} + +/** + * CONTRACT §5 — "true if ANY allow preceded a deny". + * + * This is the field that separates "nothing happened" from "some steps applied, then + * stopped". A caller that cannot tell those apart will retry a half-applied task, so the + * literal rule is implemented literally: a run where everything was allowed did not stop, + * and is therefore false. + */ +export function appliedBeforeStop(approvals: ApprovalRecord[]): boolean { + const firstDeny = approvals.findIndex((entry) => entry.decision === "deny"); + if (firstDeny === -1) return false; + return approvals + .slice(0, firstDeny) + .some((entry) => entry.decision === "allow"); +} + +function asRecord(value: unknown): Record { + return typeof value === "object" && value !== null && !Array.isArray(value) + ? (value as Record) + : {}; +} + +/** + * Atlas's own status wins when it declares one; otherwise it is derived from what we can + * see. Deriving is a last resort, not an interpretation of the answer. + */ +export function deriveStatus( + response: AgentResponse, + approvals: ApprovalRecord[], + needsApproval: unknown[], +): AskStatus { + if (response.status === 429) return "rate_limited"; + // Status 0 (unreachable) lands here too, which is what it is: a failed call. + if (response.status < 200 || response.status >= 300) return "error"; + + const declared = asRecord(response.body).status; + if ( + typeof declared === "string" && + (ASK_STATUSES as readonly string[]).includes(declared) + ) { + return declared as AskStatus; + } + + const denied = approvals.some((entry) => entry.decision === "deny"); + return denied || needsApproval.length > 0 ? "blocked" : "ok"; +} + +/** Assemble CONTRACT §5's result. Atlas's payload is carried, never rewritten. */ +export function buildResult( + response: AgentResponse, + approvals: ApprovalRecord[], + relayUsed: boolean, +): AskResult { + const payload = asRecord(response.body); + const needsApproval = Array.isArray(payload.needs_approval) + ? (payload.needs_approval as unknown[]) + : []; + const status = deriveStatus(response, approvals, needsApproval); + + return { + ok: status === "ok", + status, + // The product's answer, as the product wrote it. Nothing here summarises or re-reads it. + answer: payload.answer ?? null, + approvals, + needs_approval: needsApproval, + applied_before_stop: appliedBeforeStop(approvals), + permission_relay: relayUsed + ? { used: true, reason: "", detail: RELAY_ON_DETAIL } + : // CONTRACT §7's last row: no elicitation capability means the field was never sent. + { used: false, reason: "no_human", detail: RELAY_OFF_DETAIL }, + atlas_response: response.body ?? null, + ...(response.status === 0 && response.error ? { error: response.error } : {}), + }; +} + +/** + * A result for a call that never reached, or never got past, Atlas. + * + * It keeps §5's shape — including the approval trail — because a failure AFTER an approval + * was granted is exactly the case where a caller most needs to know something may already + * have been applied. + */ +export function errorResult( + message: string, + approvals: ApprovalRecord[], + relayUsed: boolean, +): AskResult { + return { + ok: false, + status: "error", + answer: null, + approvals, + needs_approval: [], + applied_before_stop: appliedBeforeStop(approvals), + permission_relay: relayUsed + ? { used: true, reason: "", detail: RELAY_ON_DETAIL } + : { used: false, reason: "no_human", detail: RELAY_OFF_DETAIL }, + atlas_response: null, + error: message, + }; +} diff --git a/src/tools/ask-browserstack/types.ts b/src/tools/ask-browserstack/types.ts new file mode 100644 index 0000000..f3da7f2 --- /dev/null +++ b/src/tools/ask-browserstack/types.ts @@ -0,0 +1,107 @@ +/** + * CONTRACT v1, expressed as types. + * + * Everything in this file is one half of a wire format whose other half is being written + * against `~/.claude/orchestration/shared/CONTRACT.md` by a different session, in a + * different repo, at the same time. A field renamed here to read better is a field the + * other half will never send. Nothing here changes without changing that document first. + */ + +export const PRODUCTS = ["tm", "a11y", "tra"] as const; +export type Product = (typeof PRODUCTS)[number]; + +/** CONTRACT §2 — the body Atlas POSTs to our callback when its gate needs a human. */ +export interface PermissionAsk { + /** + * Atlas's own `perm-<32 hex>` uuid4, carried, never re-minted. + * + * Commit 737f6f57 replaced a per-Bridge sequential scheme with this one after ids + * collided across pods and left a turn blocked until its 300s timeout. A second id + * scheme on this side would reintroduce that bug on a new axis, so we echo theirs. + */ + perm_id: string; + product: string; + /** "ask-always" | "ask-once" — Atlas's vocabulary, not re-interpreted here. */ + mode: string; + /** + * The model's `thought`: product language, route-free. + * + * The privacy boundary is that `op_key`, `method`, `path` and `host` stay inside Atlas's + * private record. This field is the whole of what a human is shown. + */ + description: string; +} + +export type Decision = "allow" | "deny"; + +/** + * CONTRACT §2/§7. Advisory ONLY — `decision` alone decides whether the action proceeds. + * It exists so a result can distinguish "a human said no" from "no human was there". + */ +export type DecisionReason = "" | "declined" | "cancelled" | "no_human" | "timeout"; + +/** CONTRACT §2 — what we answer the still-open callback request with. */ +export interface PermissionDecision { + perm_id: string; + decision: Decision; + reason: DecisionReason; +} + +/** CONTRACT §1 — the one new optional field on `POST /agent`. */ +export interface PermissionRelay { + callback_url: string; + token: string; +} + +/** + * CONTRACT §1 — the `POST /agent` body. + * + * `permission_relay` is OMITTED ENTIRELY, not sent empty or null, when the client cannot + * elicit: its absence is what selects Atlas's read-only `HeadlessGate`, which is today's + * byte-identical behaviour and the opencode/goose path. + */ +export interface AgentRequest { + task: string; + product: string; + permission_relay?: PermissionRelay; +} + +/** CONTRACT §5 — one entry per ask we relayed, in the order they arrived. */ +export interface ApprovalRecord { + description: string; + decision: Decision; + reason: string; +} + +export const ASK_STATUSES = ["ok", "blocked", "error", "rate_limited"] as const; +export type AskStatus = (typeof ASK_STATUSES)[number]; + +/** CONTRACT §5 — the single tool result. */ +export interface AskResult { + ok: boolean; + status: AskStatus; + answer: unknown; + /** + * The approval trail. Without it a caller cannot tell "nothing happened" from "some + * steps applied, then stopped", and will retry a half-applied task. + */ + approvals: ApprovalRecord[]; + needs_approval: unknown[]; + applied_before_stop: boolean; + /** Why a write may have been refused, in the reason vocabulary of CONTRACT §7. */ + permission_relay: { + used: boolean; + reason: DecisionReason; + detail: string; + }; + /** + * Atlas's public payload, verbatim. + * + * The mapped fields above are the contract; this is the belt to their braces. The two + * halves are being built in parallel, so a field named slightly differently on the other + * side would otherwise silently become `null` here rather than reaching the caller. + */ + atlas_response: unknown; + /** Only set when the call itself failed before or during egress. */ + error?: string; +} diff --git a/tests/tools/askBrowserstack.test.ts b/tests/tools/askBrowserstack.test.ts new file mode 100644 index 0000000..aeb7990 --- /dev/null +++ b/tests/tools/askBrowserstack.test.ts @@ -0,0 +1,291 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + CALLBACK_PATH, + CallbackListener, + parseAsk, + startCallbackListener, +} from "../../src/tools/ask-browserstack/callback.js"; +import { AskError, atlasBaseUrl } from "../../src/tools/ask-browserstack/config.js"; +import { + appliedBeforeStop, + buildResult, + decide, + deriveStatus, +} from "../../src/tools/ask-browserstack/relay.js"; +import { ApprovalRecord } from "../../src/tools/ask-browserstack/types.js"; + +const PERM = "perm-" + "a".repeat(32); + +function ask(overrides: Record = {}) { + return { + perm_id: PERM, + product: "tm", + mode: "ask-always", + description: "Create the folder \"Regression\" under Sprint 42.", + ...overrides, + }; +} + +async function post( + listener: CallbackListener, + body: unknown, + token: string | null = listener.token, + path = CALLBACK_PATH, + method = "POST", +) { + const response = await fetch( + listener.url.replace(CALLBACK_PATH, path), + { + method, + headers: { + "Content-Type": "application/json", + ...(token === null ? {} : { Authorization: `Bearer ${token}` }), + }, + body: typeof body === "string" ? body : JSON.stringify(body), + }, + ); + return { status: response.status, body: await response.json().catch(() => null) }; +} + +describe("decide — CONTRACT §7, and nothing but", () => { + it("allows only an explicit accept AND confirm: true", () => { + expect(decide({ action: "accept", content: { confirm: true } })) + .toEqual({ decision: "allow", reason: "" }); + }); + + it("denies accept + confirm: false as the human saying no", () => { + expect(decide({ action: "accept", content: { confirm: false } })) + .toEqual({ decision: "deny", reason: "declined" }); + }); + + it("denies a decline", () => { + expect(decide({ action: "decline" })) + .toEqual({ decision: "deny", reason: "declined" }); + }); + + it("denies a cancel, and says WHICH — no human was there", () => { + // The load-bearing row: a headless Claude Code returns cancel, so an unattended run + // must never be able to self-approve. + expect(decide({ action: "cancel" })) + .toEqual({ decision: "deny", reason: "cancelled" }); + }); + + it("does not accept a truthy stand-in for consent", () => { + // A string "true" or a 1 is a client bug, not an approval. + expect(decide({ action: "accept", content: { confirm: "true" } }).decision).toBe("deny"); + expect(decide({ action: "accept", content: {} }).decision).toBe("deny"); + expect(decide({ action: "accept" }).decision).toBe("deny"); + }); + + it("treats an action it does not recognise as no answer at all", () => { + expect(decide({ action: "something-new" } as never).decision).toBe("deny"); + }); +}); + +describe("applied_before_stop — the field that stops a half-applied retry", () => { + const allow: ApprovalRecord = { description: "a", decision: "allow", reason: "" }; + const deny: ApprovalRecord = { description: "b", decision: "deny", reason: "declined" }; + + it("is false when nothing was asked", () => { + expect(appliedBeforeStop([])).toBe(false); + }); + + it("is false when the FIRST thing asked was refused: nothing happened", () => { + expect(appliedBeforeStop([deny, allow])).toBe(false); + }); + + it("is true when an allow preceded a deny: some steps applied, then it stopped", () => { + expect(appliedBeforeStop([allow, deny])).toBe(true); + }); + + it("is false when everything was allowed, because nothing stopped", () => { + expect(appliedBeforeStop([allow, allow])).toBe(false); + }); +}); + +describe("result assembly", () => { + it("prefers the status Atlas declared over anything it could infer", () => { + expect(deriveStatus({ status: 200, body: { status: "blocked" } }, [], [])).toBe("blocked"); + }); + + it("calls a run blocked when something was denied or left needing approval", () => { + const denied: ApprovalRecord[] = [{ description: "d", decision: "deny", reason: "cancelled" }]; + expect(deriveStatus({ status: 200, body: {} }, denied, [])).toBe("blocked"); + expect(deriveStatus({ status: 200, body: {} }, [], ["a write"])).toBe("blocked"); + expect(deriveStatus({ status: 200, body: {} }, [], [])).toBe("ok"); + }); + + it("maps 429 to rate_limited and every other non-2xx, including 0, to error", () => { + expect(deriveStatus({ status: 429, body: {} }, [], [])).toBe("rate_limited"); + expect(deriveStatus({ status: 500, body: {} }, [], [])).toBe("error"); + expect(deriveStatus({ status: 0, body: null }, [], [])).toBe("error"); + }); + + it("carries Atlas's payload through and never rewrites the answer", () => { + const body = { status: "ok", answer: "Created folder 12.", needs_approval: [], extra: "kept" }; + const result = buildResult({ status: 200, body }, [], true); + expect(result.ok).toBe(true); + expect(result.answer).toBe("Created folder 12."); + // Belt and braces: nothing Atlas sent is lost, even a field this side does not map. + expect(result.atlas_response).toEqual(body); + expect(result.permission_relay).toEqual({ + used: true, reason: "", detail: expect.stringContaining("asked before each change"), + }); + }); + + it("explains a read-only run rather than leaving a refused write unexplained", () => { + const result = buildResult( + { status: 200, body: { status: "blocked", answer: "", needs_approval: ["create folder"] } }, + [], false, + ); + expect(result.status).toBe("blocked"); + expect(result.needs_approval).toEqual(["create folder"]); + expect(result.permission_relay.used).toBe(false); + expect(result.permission_relay.reason).toBe("no_human"); + expect(result.permission_relay.detail).toMatch(/does not support MCP elicitation/); + }); +}); + +describe("the loopback callback listener", () => { + const open: CallbackListener[] = []; + + afterEach(async () => { + while (open.length) await open.pop()!.close(); + }); + + async function listen(handler: Parameters[0]) { + const listener = await startCallbackListener(handler); + open.push(listener); + return listener; + } + + it("binds loopback on a port the OS chose, never 0.0.0.0 and never a fixed one", async () => { + const first = await listen(async () => ({ perm_id: PERM, decision: "deny", reason: "" })); + const second = await listen(async () => ({ perm_id: PERM, decision: "deny", reason: "" })); + + expect(first.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/atlas-permission$/); + expect(first.url).not.toBe(second.url); + // Two concurrent calls must not be able to answer each other's asks. + expect(first.token).not.toBe(second.token); + expect(first.token).toHaveLength(64); + }); + + it("answers a properly authenticated ask, echoing perm_id exactly", async () => { + const seen: string[] = []; + const listener = await listen(async (incoming) => { + seen.push(incoming.description); + return { perm_id: incoming.perm_id, decision: "allow", reason: "" }; + }); + + const response = await post(listener, ask()); + expect(response.status).toBe(200); + expect(response.body).toEqual({ perm_id: PERM, decision: "allow", reason: "" }); + expect(seen).toEqual(["Create the folder \"Regression\" under Sprint 42."]); + }); + + it("401s a callback with a wrong or missing token, and elicits NOTHING", async () => { + // A stray local process must not be able to make an approval prompt appear. + const handler = vi.fn(); + const listener = await listen(handler as never); + + expect((await post(listener, ask(), null)).status).toBe(401); + expect((await post(listener, ask(), "")).status).toBe(401); + expect((await post(listener, ask(), "not-the-token")).status).toBe(401); + expect((await post(listener, ask(), listener.token + "x")).status).toBe(401); + expect(handler).not.toHaveBeenCalled(); + }); + + it("refuses a perm_id that is not Atlas's shape, without asking anyone", async () => { + const handler = vi.fn(); + const listener = await listen(handler as never); + + expect((await post(listener, ask({ perm_id: "perm-nope" }))).status).toBe(400); + expect((await post(listener, ask({ perm_id: "1234" }))).status).toBe(400); + expect((await post(listener, ask({ perm_id: undefined }))).status).toBe(400); + expect(handler).not.toHaveBeenCalled(); + }); + + it("refuses a blank description: a prompt asking a human to approve nothing", async () => { + const handler = vi.fn(); + const listener = await listen(handler as never); + expect((await post(listener, ask({ description: " " }))).status).toBe(400); + expect(handler).not.toHaveBeenCalled(); + }); + + it("refuses a body it cannot parse", async () => { + const handler = vi.fn(); + const listener = await listen(handler as never); + expect((await post(listener, "{not json")).status).toBe(400); + expect(handler).not.toHaveBeenCalled(); + }); + + it("404s anything that is not a POST to the callback path", async () => { + const handler = vi.fn(); + const listener = await listen(handler as never); + expect((await post(listener, ask(), listener.token, "/", "POST")).status).toBe(404); + expect((await post(listener, ask(), listener.token, CALLBACK_PATH, "PUT")).status).toBe(404); + expect(handler).not.toHaveBeenCalled(); + }); + + it("fails closed with a non-200 when the relay itself throws", async () => { + const listener = await listen(async () => { + throw new Error("client went away"); + }); + // Atlas maps a non-200 to a deny, so a broken relay cannot approve anything. + expect((await post(listener, ask())).status).toBe(500); + }); + + it("stops accepting connections once closed", async () => { + const listener = await startCallbackListener(async (incoming) => ({ + perm_id: incoming.perm_id, decision: "allow", reason: "", + })); + const url = listener.url; + await listener.close(); + await expect(fetch(url, { method: "POST", body: "{}" })).rejects.toThrow(); + }); +}); + +describe("parseAsk", () => { + it("keeps only the four fields of CONTRACT §2", () => { + // op_key, method, path and host stay on Atlas's side; if one ever arrived it would not + // be carried onward from here. + expect(parseAsk({ ...ask(), op_key: "x", method: "POST", path: "/api/v1/x" })) + .toEqual(ask()); + }); + + it("rejects a non-object", () => { + expect(parseAsk(null)).toBeNull(); + expect(parseAsk([ask()])).toBeNull(); + expect(parseAsk("perm-x")).toBeNull(); + }); +}); + +describe("host resolution", () => { + const saved = { ...process.env }; + + afterEach(() => { + process.env = { ...saved }; + }); + + it("refuses by name rather than guessing a host", () => { + delete process.env.ASK_BROWSERSTACK_ATLAS_URL; + delete process.env.ASK_BROWSERSTACK_ENV; + delete process.env.CAPABILITY_REGISTRY_ENV; + expect(() => atlasBaseUrl()).toThrow(AskError); + expect(() => atlasBaseUrl()).toThrow(/ASK_BROWSERSTACK_ATLAS_URL/); + }); + + it("lets the named environment pick the host, so preprod cannot fall back to prod", () => { + delete process.env.ASK_BROWSERSTACK_ATLAS_URL; + process.env.ASK_BROWSERSTACK_ENV = "preprod"; + process.env.ASK_BROWSERSTACK_ATLAS_URL_PREPROD = "https://atlas-preprod.example/"; + expect(atlasBaseUrl()).toBe("https://atlas-preprod.example"); + }); + + it("lets an explicit override win", () => { + process.env.ASK_BROWSERSTACK_ENV = "preprod"; + process.env.ASK_BROWSERSTACK_ATLAS_URL = "https://atlas.example/"; + expect(atlasBaseUrl()).toBe("https://atlas.example"); + }); +}); diff --git a/tests/tools/askBrowserstackE2E.test.ts b/tests/tools/askBrowserstackE2E.test.ts new file mode 100644 index 0000000..dba68f2 --- /dev/null +++ b/tests/tools/askBrowserstackE2E.test.ts @@ -0,0 +1,393 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { addAskBrowserstackAITool } from "../../src/tools/ask-browserstack/register.js"; + +/** Captured before anything stubs the global, so the loopback hop stays real. */ +const realFetch = globalThis.fetch.bind(globalThis); + +const CONFIG = { + "browserstack-username": "ing_Xx", + "browserstack-access-key": "SECRET", +} as any; + +const PERM_A = "perm-" + "a".repeat(32); +const PERM_B = "perm-" + "b".repeat(32); + +interface AtlasCall { + url: string; + headers: Record; + body: any; +} + +/** + * Plays Atlas: receives POST /agent, then calls the MCP server back over REAL loopback + * HTTP for each ask before answering. Every hop except Atlas's own logic is genuine. + */ +function atlas(options: { + asks?: { perm_id: string; description: string }[]; + token?: (real: string) => string; + payload?: (decisions: any[]) => unknown; + throws?: boolean; +}) { + const calls: AtlasCall[] = []; + const decisions: any[] = []; + + const stub = async (url: string, init: any) => { + const body = JSON.parse(init.body); + calls.push({ url: String(url), headers: init.headers, body }); + if (options.throws) throw new Error("connection reset"); + + for (const ask of options.asks || []) { + const relay = body.permission_relay; + const response = await realFetch(relay.callback_url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${options.token ? options.token(relay.token) : relay.token}`, + }, + body: JSON.stringify({ ...ask, product: body.product, mode: "ask-always" }), + }); + decisions.push({ status: response.status, body: await response.json() }); + } + + const payload = options.payload + ? options.payload(decisions) + : { status: "ok", answer: "done", needs_approval: [] }; + return { + status: 200, + headers: { get: () => "application/json" }, + json: async () => payload, + }; + }; + + vi.stubGlobal("fetch", stub); + return { calls, decisions }; +} + +async function buildServer() { + const { BrowserStackMcpServer } = await import("../../src/server-factory.js"); + return new BrowserStackMcpServer(CONFIG); +} + +/** Give the server a client that can (or cannot) be prompted, and script its answers. */ +function fakeClient( + mcp: McpServer, + capabilities: Record | undefined, + answers: any[] = [], +) { + vi.spyOn(mcp.server, "getClientCapabilities").mockReturnValue(capabilities as never); + const elicit = vi.spyOn(mcp.server, "elicitInput"); + for (const answer of answers) { + if (answer instanceof Error) elicit.mockRejectedValueOnce(answer); + else elicit.mockResolvedValueOnce(answer); + } + return elicit; +} + +async function call(tools: Record, args = { product: "tm", query: "make a folder" }) { + const result = await tools.askBrowserstackAI.handler(args, {} as any); + return { result, payload: JSON.parse(result.content[0].text) }; +} + +describe("askBrowserstackAI, end to end through the server factory", () => { + beforeEach(() => { + process.env.ASK_BROWSERSTACK_ATLAS_URL = "https://atlas.example"; + delete process.env.ASK_BROWSERSTACK_DISABLED; + delete process.env.ASK_BROWSERSTACK_ENV; + vi.resetModules(); + }); + + afterEach(() => { + delete process.env.ASK_BROWSERSTACK_ATLAS_URL; + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it("registers alongside the existing surface, and honours its kill switch", async () => { + const server = await buildServer(); + expect(server.getTools().askBrowserstackAI).toBeDefined(); + expect(server.getTools().listTestCases ?? server.getTools().createTestCase).toBeDefined(); + + process.env.ASK_BROWSERSTACK_DISABLED = "true"; + vi.resetModules(); + expect((await buildServer()).getTools().askBrowserstackAI).toBeUndefined(); + }); + + describe("the client CAN elicit", () => { + it("relays an ask, approves it, and forwards the caller's own credentials", async () => { + const server = await buildServer(); + const elicit = fakeClient(server.getInstance(), { elicitation: {} }, [ + { action: "accept", content: { confirm: true } }, + ]); + const stub = atlas({ + asks: [{ perm_id: PERM_A, description: "Create folder \"Regression\"." }], + payload: () => ({ status: "ok", answer: "Created folder 12.", needs_approval: [] }), + }); + + const { payload } = await call(server.getTools()); + + // 1. the request: relay offered, on loopback, with a per-run token + expect(stub.calls[0].url).toBe("https://atlas.example/agent"); + expect(stub.calls[0].headers["Api-Token"]).toBe("ing_Xx:SECRET"); + expect(stub.calls[0].body.task).toBe("make a folder"); + expect(stub.calls[0].body.product).toBe("tm"); + expect(stub.calls[0].body.permission_relay.callback_url) + .toMatch(/^http:\/\/127\.0\.0\.1:\d+\/atlas-permission$/); + expect(stub.calls[0].body.permission_relay.token).toHaveLength(64); + + // 2. the prompt: Atlas's description verbatim, and a boolean confirm + expect(elicit).toHaveBeenCalledTimes(1); + const request = elicit.mock.calls[0][0] as any; + expect(request.message).toBe("Create folder \"Regression\"."); + expect(request.requestedSchema.properties.confirm.type).toBe("boolean"); + expect(request.requestedSchema.required).toEqual(["confirm"]); + // The inner rung of the timeout ladder, shorter than Atlas's 300s gate. + expect((elicit.mock.calls[0][1] as any).timeout).toBe(270_000); + + // 3. the answer on the wire, echoing Atlas's own id + expect(stub.decisions[0]) + .toEqual({ status: 200, body: { perm_id: PERM_A, decision: "allow", reason: "" } }); + + // 4. the result + expect(payload.ok).toBe(true); + expect(payload.answer).toBe("Created folder 12."); + expect(payload.approvals) + .toEqual([{ description: "Create folder \"Regression\".", decision: "allow", reason: "" }]); + expect(payload.applied_before_stop).toBe(false); + expect(payload.permission_relay.used).toBe(true); + }); + + it("denies when the human confirms false, and never asks a second time", async () => { + const server = await buildServer(); + const elicit = fakeClient(server.getInstance(), { elicitation: {} }, [ + { action: "accept", content: { confirm: false } }, + ]); + const stub = atlas({ + asks: [{ perm_id: PERM_A, description: "Delete the sprint." }], + payload: () => ({ status: "blocked", answer: "", needs_approval: ["Delete the sprint."] }), + }); + + const { payload } = await call(server.getTools()); + + expect(stub.decisions[0].body) + .toEqual({ perm_id: PERM_A, decision: "deny", reason: "declined" }); + expect(elicit).toHaveBeenCalledTimes(1); + expect(payload.status).toBe("blocked"); + expect(payload.approvals[0].reason).toBe("declined"); + expect(payload.applied_before_stop).toBe(false); + }); + + it("denies a decline", async () => { + const server = await buildServer(); + fakeClient(server.getInstance(), { elicitation: {} }, [{ action: "decline" }]); + const stub = atlas({ asks: [{ perm_id: PERM_A, description: "Archive the plan." }] }); + + const { payload } = await call(server.getTools()); + expect(stub.decisions[0].body) + .toEqual({ perm_id: PERM_A, decision: "deny", reason: "declined" }); + expect(payload.approvals[0].decision).toBe("deny"); + }); + + it("denies a cancel as 'nobody was there', not as a refusal, and does not retry", async () => { + const server = await buildServer(); + const elicit = fakeClient(server.getInstance(), { elicitation: {} }, [{ action: "cancel" }]); + const stub = atlas({ asks: [{ perm_id: PERM_A, description: "Archive the plan." }] }); + + const { payload } = await call(server.getTools()); + + // This is the security property: an unattended run cannot self-approve, and a second + // prompt would only be an attempt to wear a human down. + expect(stub.decisions[0].body) + .toEqual({ perm_id: PERM_A, decision: "deny", reason: "cancelled" }); + expect(elicit).toHaveBeenCalledTimes(1); + expect(payload.approvals[0].reason).toBe("cancelled"); + }); + + it("denies on an elicitation timeout without killing the run", async () => { + const server = await buildServer(); + fakeClient(server.getInstance(), { elicitation: {} }, [ + new McpError(ErrorCode.RequestTimeout, "timed out"), + ]); + const stub = atlas({ asks: [{ perm_id: PERM_A, description: "Archive the plan." }] }); + + const { payload } = await call(server.getTools()); + expect(stub.decisions[0]) + .toEqual({ status: 200, body: { perm_id: PERM_A, decision: "deny", reason: "timeout" } }); + expect(payload.approvals[0].reason).toBe("timeout"); + }); + + it("fails closed with a non-200 when the relay breaks in an unexpected way", async () => { + const server = await buildServer(); + fakeClient(server.getInstance(), { elicitation: {} }, [new Error("client went away")]); + const stub = atlas({ asks: [{ perm_id: PERM_A, description: "Archive the plan." }] }); + + const { payload } = await call(server.getTools()); + // Atlas's fail-closed rule reads any non-200 as a deny. + expect(stub.decisions[0].status).toBe(500); + expect(payload.approvals[0]).toEqual({ + description: "Archive the plan.", decision: "deny", reason: "error", + }); + }); + + it("ignores a callback that cannot present the run's token, and elicits nothing", async () => { + const server = await buildServer(); + const elicit = fakeClient(server.getInstance(), { elicitation: {} }, [ + { action: "accept", content: { confirm: true } }, + ]); + const stub = atlas({ + asks: [{ perm_id: PERM_A, description: "Create folder." }], + token: () => "a-stray-local-process", + }); + + await call(server.getTools()); + expect(stub.decisions[0].status).toBe(401); + expect(elicit).not.toHaveBeenCalled(); + }); + + it("says some steps applied before it stopped", async () => { + const server = await buildServer(); + fakeClient(server.getInstance(), { elicitation: {} }, [ + { action: "accept", content: { confirm: true } }, + { action: "decline" }, + ]); + atlas({ + asks: [ + { perm_id: PERM_A, description: "Create folder \"Regression\"." }, + { perm_id: PERM_B, description: "Move 40 test cases into it." }, + ], + payload: () => ({ + status: "blocked", answer: "Created the folder; the move was refused.", + needs_approval: ["Move 40 test cases into it."], + }), + }); + + const { payload } = await call(server.getTools()); + // The whole point of the field: a caller must not retry this task from scratch. + expect(payload.applied_before_stop).toBe(true); + expect(payload.approvals.map((a: any) => a.decision)).toEqual(["allow", "deny"]); + expect(payload.needs_approval).toEqual(["Move 40 test cases into it."]); + }); + + it("tears the listener down once the call ends, even when the call failed", async () => { + const server = await buildServer(); + fakeClient(server.getInstance(), { elicitation: {} }, []); + const stub = atlas({ throws: true }); + + const { payload } = await call(server.getTools()); + expect(payload.ok).toBe(false); + expect(payload.status).toBe("error"); + + // The port must not survive the call that opened it. + const url = stub.calls[0].body.permission_relay.callback_url; + await expect(realFetch(url, { method: "POST", body: "{}" })).rejects.toThrow(); + }); + }); + + describe("the client CANNOT elicit — the opencode/goose path", () => { + it("omits permission_relay entirely and explains the read-only run", async () => { + const server = await buildServer(); + const elicit = fakeClient(server.getInstance(), { roots: {} }, []); + const stub = atlas({ + payload: () => ({ + status: "blocked", answer: "I could not create the folder.", + needs_approval: ["Create folder \"Regression\"."], + }), + }); + + const { payload } = await call(server.getTools()); + + // Absence is what selects Atlas's read-only HeadlessGate — not an empty object. + expect("permission_relay" in stub.calls[0].body).toBe(false); + expect(Object.keys(stub.calls[0].body).sort()).toEqual(["product", "task"]); + expect(elicit).not.toHaveBeenCalled(); + + expect(payload.status).toBe("blocked"); + expect(payload.approvals).toEqual([]); + expect(payload.applied_before_stop).toBe(false); + expect(payload.needs_approval).toEqual(["Create folder \"Regression\"."]); + expect(payload.permission_relay).toEqual({ + used: false, + reason: "no_human", + detail: expect.stringMatching(/does not support MCP elicitation/), + }); + }); + + it("does not depend on sampling, which Claude Code does not declare", async () => { + const server = await buildServer(); + // Exactly what Claude Code sends: roots and elicitation, no sampling. + fakeClient(server.getInstance(), { roots: { listChanged: true }, elicitation: {} }, [ + { action: "accept", content: { confirm: true } }, + ]); + const stub = atlas({ asks: [{ perm_id: PERM_A, description: "Create folder." }] }); + + const { payload } = await call(server.getTools()); + expect(stub.calls[0].body.permission_relay).toBeDefined(); + expect(payload.approvals[0].decision).toBe("allow"); + }); + + it("treats a client with no capabilities at all as unable to be asked", async () => { + const server = await buildServer(); + fakeClient(server.getInstance(), undefined, []); + const stub = atlas({}); + + const { payload } = await call(server.getTools()); + expect(stub.calls[0].body.permission_relay).toBeUndefined(); + expect(payload.permission_relay.reason).toBe("no_human"); + }); + }); + + it("refuses by name when no host is configured, without calling anything", async () => { + delete process.env.ASK_BROWSERSTACK_ATLAS_URL; + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + const server = await buildServer(); + fakeClient(server.getInstance(), { elicitation: {} }, []); + + const { result, payload } = await call(server.getTools()); + expect(result.isError).toBe(true); + expect(payload.error).toMatch(/ASK_BROWSERSTACK_ATLAS_URL/); + expect(fetchSpy).not.toHaveBeenCalled(); + }); +}); + +describe("askBrowserstackAI, against the injected seam", () => { + afterEach(() => vi.restoreAllMocks()); + + it("refuses rather than calling Atlas unauthenticated", async () => { + const mcp = new McpServer({ name: "t", version: "0" }); + const transport = vi.fn(); + const tools = addAskBrowserstackAITool(mcp, { + agentUrl: () => "https://atlas.example/agent", + credentialsFor: () => ({ username: "u", accessKey: "" }), + transport: transport as never, + startListener: (async () => ({ url: "x", token: "y", close: async () => {} })) as never, + }); + + const { payload } = await call(tools); + expect(payload.ok).toBe(false); + expect(payload.error).toMatch(/not authenticated/); + expect(transport).not.toHaveBeenCalled(); + }); + + it("closes the listener even when the transport throws", async () => { + const mcp = new McpServer({ name: "t", version: "0" }); + vi.spyOn(mcp.server, "getClientCapabilities").mockReturnValue({ elicitation: {} } as never); + const close = vi.fn(async () => {}); + const tools = addAskBrowserstackAITool(mcp, { + agentUrl: () => "https://atlas.example/agent", + credentialsFor: () => ({ username: "u", accessKey: "k" }), + transport: (async () => { + throw new Error("boom"); + }) as never, + startListener: (async () => ({ + url: "http://127.0.0.1:1/atlas-permission", token: "t", close, + })) as never, + }); + + const { payload } = await call(tools); + expect(payload.error).toBe("boom"); + expect(close).toHaveBeenCalledTimes(1); + }); +}); From 50912636c22ec76380f4160047c4e2d60c04a1c9 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Mon, 24 Aug 2026 15:32:15 +0530 Subject: [PATCH 07/31] Align to the verified Atlas response, and frame the prompt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONTRACT v1.1 replaced four inferences with facts, and two of them were traps. `needs_approval` is absent when empty rather than `[]` — Atlas's `public()` omits the key entirely, as it does for narration, artifacts, error, cost_breach and usage. And the emitted status vocabulary is ok | error | blocked | rate_limited; `interrupted` appears in a dataclass comment but never on this path. Both were already read defensively, so this pins them down with tests and comments rather than changing behaviour. Atlas now reports whether the relay it was offered actually ran, which is a fact only it knows, so its verdict is preferred over anything inferred here. The reason worth the most care is `disabled`: the knob is off server-side, so every write was refused for a configuration reason and nobody declined anything. A caller who cannot tell that from a human saying no retries forever, so each reason gets its own sentence and `disabled` says outright that nobody declined. An unrecognised reason degrades to a sentence too — a newer Atlas must not be able to throw inside a result — and is length-bounded, because it arrives off the wire and ends up in front of a person. The prompt is now framed with the product, which v1.1 approves: a bare sentence with no attribution is a worse prompt than a framed one. The product is all the callback carries, and the description still goes through verbatim, because the human must approve what the model actually said, not a paraphrase. Two judgment calls beyond the brief, both flagged for veto in the result file. Atlas's own `error` string is lifted to the top level instead of being left for the caller to dig out of `atlas_response`. And `isError` now marks a call that failed rather than one that was refused: a blocked run is the feature working, and rendering a correct refusal in red invites the retry loop these distinct reasons exist to prevent. --- src/tools/ask-browserstack/register.ts | 24 +++- src/tools/ask-browserstack/relay.ts | 159 +++++++++++++++++++++++-- src/tools/ask-browserstack/types.ts | 33 ++++- tests/tools/askBrowserstack.test.ts | 150 +++++++++++++++++++++++ tests/tools/askBrowserstackE2E.test.ts | 75 +++++++++++- 5 files changed, 419 insertions(+), 22 deletions(-) diff --git a/src/tools/ask-browserstack/register.ts b/src/tools/ask-browserstack/register.ts index 6436c6a..21cda11 100644 --- a/src/tools/ask-browserstack/register.ts +++ b/src/tools/ask-browserstack/register.ts @@ -51,7 +51,7 @@ import { CallbackListener, startCallbackListener, } from "./callback.js"; -import { buildResult, decide, errorResult } from "./relay.js"; +import { buildResult, decide, elicitationMessage, errorResult } from "./relay.js"; import { AgentRequest, ApprovalRecord, @@ -86,10 +86,19 @@ const CONFIRM_TITLE = "Approve this change"; const CONFIRM_DESCRIPTION = "Yes, make this change. Anything else — including dismissing this prompt — refuses it."; +/** + * `isError` marks a call that FAILED, not one that was refused. + * + * A `blocked` run is the feature working: the agent asked, a human said no, and the trail + * says so. Flagging that as a tool error makes a client render a correct refusal in red and + * — worse — invites it to retry, which is exactly the "retry forever" loop the distinct + * `permission_relay` reasons exist to prevent. `ok` still means `status === "ok"`. + */ function toResult(payload: AskResult): CallToolResult { + const failed = payload.status === "error" || payload.status === "rate_limited"; return { content: [{ type: "text", text: JSON.stringify(payload) }], - ...(payload.ok ? {} : { isError: true }), + ...(failed ? { isError: true } : {}), }; } @@ -113,9 +122,14 @@ async function relayOneAsk( answer = await server.server.elicitInput( { mode: "form", - // Atlas's `thought`, verbatim: product language, already free of the route, - // op_key and host that stay on its side of the boundary. - message: ask.description, + // Framed with the PRODUCT and nothing else (v1.1 §G): a bare sentence with no + // attribution is a worse prompt than a framed one, and `product` is all the + // callback carries — the route, method, path and op_key never reach this side by + // design. The description itself goes through VERBATIM: paraphrasing it would mean + // the human approves something other than what the model actually said. Atlas + // route-checks it first (v1.1 §A), so one that quoted an internal path arrives as a + // withheld-placeholder sentence, which reads correctly after the prefix. + message: elicitationMessage(ask.product, ask.description), requestedSchema: { type: "object", properties: { diff --git a/src/tools/ask-browserstack/relay.ts b/src/tools/ask-browserstack/relay.ts index e1dbb98..b7fcf0a 100644 --- a/src/tools/ask-browserstack/relay.ts +++ b/src/tools/ask-browserstack/relay.ts @@ -19,11 +19,105 @@ export const RELAY_ON_DETAIL = "This client can prompt you, so BrowserStack asked before each change and the answers " + "are in `approvals`."; -export const RELAY_OFF_DETAIL = - "This client does not support MCP elicitation, so there was no way to ask you mid-run. " + - "BrowserStack ran read-only: anything that would have changed data was refused and is " + - "listed in `needs_approval`. Re-run the same request from a client that supports " + - "elicitation to be asked for confirmation instead."; +/** + * One sentence per way the relay can fail to run, because they call for different things + * from the person reading them, and a caller who cannot tell them apart retries forever. + * + * `no_human` is ours (CONTRACT §7's last row); the rest are Atlas's (v1.1 §D). + */ +export const RELAY_OFF_DETAILS: Record = { + no_human: + "This client does not support MCP elicitation, so there was no way to ask you mid-run. " + + "BrowserStack ran read-only: anything that would have changed data was refused and is " + + "listed in `needs_approval`. Re-run the same request from a client that supports " + + "elicitation to be asked for confirmation instead.", + + // The one that matters most to a human. NOBODY REFUSED ANYTHING HERE — the server has the + // knob off, so saying "your change was declined" would be a lie and retrying cannot help. + disabled: + "NOBODY DECLINED THIS. BrowserStack's permission relay is switched off on the server " + + "(`delegation.permission_relay`), so it ignored the approval channel this client " + + "offered and ran read-only. Everything in `needs_approval` was refused for that " + + "configuration reason alone. Retrying will keep failing the same way until an " + + "administrator turns the relay on.", + + host_not_allowed: + "BrowserStack refused to call this client back: the loopback address it was given is " + + "not on the server's allowed-callback list, which is the guard that stops a " + + "caller-supplied URL turning the server into a request proxy. The run went read-only. " + + "This normally means BrowserStack is not running on the same host as this MCP server.", + + malformed: + "BrowserStack could not use the approval channel this client offered and ran read-only. " + + "That is a bug on this side, not something you did; everything in `needs_approval` was " + + "refused because of it.", +}; + +/** Kept for anything still importing the old single constant. */ +export const RELAY_OFF_DETAIL = RELAY_OFF_DETAILS.no_human; + +/** A reason from a newer Atlas than this build. Say so plainly rather than crash. */ +function unknownRelayDetail(used: boolean, reason: string): string { + // Bounded: this string came off the wire and goes into a result a human reads. + const quoted = JSON.stringify(reason.slice(0, 64)); + return used + ? `BrowserStack used the approval channel and reported ${quoted}, which this version ` + + "does not recognise. The answers it did collect are in `approvals`." + : `BrowserStack did not use the approval channel this client offered, reporting ` + + `${quoted}, which this version does not recognise. The run was read-only, so ` + + "anything in `needs_approval` was refused without anyone being asked."; +} + +/** The sentence that goes with a `{used, reason}` pair, whoever produced it. */ +export function relayDetail(used: boolean, reason: string): string { + if (used) return reason ? unknownRelayDetail(true, reason) : RELAY_ON_DETAIL; + return RELAY_OFF_DETAILS[reason] ?? unknownRelayDetail(false, reason); +} + +/** + * Atlas's own verdict on the relay (v1.1 §D), when it gave one. + * + * Present ONLY when we supplied a `permission_relay` block, so its absence is either "we + * never offered one" or "this Atlas predates v1.1" — neither of which is an error. A block + * we cannot read is treated as no block at all rather than half-trusted. + */ +export function atlasRelayVerdict( + payload: Record, +): { used: boolean; reason: string } | null { + const raw = payload.permission_relay; + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null; + const record = raw as Record; + if (typeof record.used !== "boolean") return null; + return { + used: record.used, + reason: typeof record.reason === "string" ? record.reason : "", + }; +} + +/** + * Product-language framing for the prompt (v1.1 §G, approved). + * + * `product` is the ONLY thing added — it is all §2 carries, and the route, method and path + * never reach this side by design. The description itself is passed through untouched: + * paraphrasing or truncating it would mean the human approves something other than what the + * model actually said, and Atlas has already route-checked it (v1.1 §A), so a description + * that quoted an internal path arrives as a withheld-placeholder sentence which reads + * perfectly well after this prefix. + */ +export const PRODUCT_LABELS: Record = { + tm: "Test Management", + a11y: "Accessibility", + tra: "Test Reporting & Analytics", +}; + +export function elicitationMessage(product: string, description: string): string { + const label = PRODUCT_LABELS[product] || product.trim(); + const who = label + ? `BrowserStack AI (${label})` + : // An unnamed product beats an empty pair of brackets. + "BrowserStack AI"; + return `${who} needs your approval to continue:\n\n${description}`; +} /** * CONTRACT §7, exactly. @@ -104,6 +198,34 @@ export function deriveStatus( return denied || needsApproval.length > 0 ? "blocked" : "ok"; } +/** + * Reconcile our view of the relay with Atlas's. + * + * ATLAS WINS WHEN IT SPOKE. It is the side that decided whether `RelayGate` actually ran, so + * its `used`/`reason` beat anything inferred here — that is the whole point of v1.1 §D, and + * `disabled` in particular is a fact only Atlas knows. The `detail` sentence stays ours. + * + * When we never offered a block, ours wins unconditionally: `no_human` describes a client + * that cannot be prompted, which Atlas is never told about and could not report. + */ +function relayVerdict( + payload: Record, + relayUsed: boolean, +): AskResult["permission_relay"] { + if (!relayUsed) { + // CONTRACT §7's last row: no elicitation capability means the field was never sent. + return { used: false, reason: "no_human", detail: RELAY_OFF_DETAILS.no_human }; + } + const verdict = atlasRelayVerdict(payload); + if (verdict) { + return { ...verdict, detail: relayDetail(verdict.used, verdict.reason) }; + } + // No verdict: an Atlas older than v1.1. We offered the channel and have no reason to + // believe it was refused, so the optimistic read is the honest one — and it is advisory + // either way, never deciding whether an action proceeded. + return { used: true, reason: "", detail: RELAY_ON_DETAIL }; +} + /** Assemble CONTRACT §5's result. Atlas's payload is carried, never rewritten. */ export function buildResult( response: AgentResponse, @@ -111,6 +233,9 @@ export function buildResult( relayUsed: boolean, ): AskResult { const payload = asRecord(response.body); + // ABSENT WHEN EMPTY, never `[]` (v1.1 §B): Atlas's `public()` omits the key entirely, as + // it does for `narration`, `artifacts`, `error`, `cost_breach` and `usage`. Missing is + // read as empty, not as a malformed response. const needsApproval = Array.isArray(payload.needs_approval) ? (payload.needs_approval as unknown[]) : []; @@ -124,15 +249,27 @@ export function buildResult( approvals, needs_approval: needsApproval, applied_before_stop: appliedBeforeStop(approvals), - permission_relay: relayUsed - ? { used: true, reason: "", detail: RELAY_ON_DETAIL } - : // CONTRACT §7's last row: no elicitation capability means the field was never sent. - { used: false, reason: "no_human", detail: RELAY_OFF_DETAIL }, + permission_relay: relayVerdict(payload, relayUsed), atlas_response: response.body ?? null, - ...(response.status === 0 && response.error ? { error: response.error } : {}), + ...atlasError(response, payload), }; } +/** + * The `error` field, from whichever side has one. + * + * Ours when there was no response to speak for itself; otherwise Atlas's own string, which + * `public()` includes ONLY when non-empty (v1.1 §B). + */ +function atlasError( + response: AgentResponse, + payload: Record, +): { error?: string } { + if (response.status === 0 && response.error) return { error: response.error }; + const reported = payload.error; + return typeof reported === "string" && reported ? { error: reported } : {}; +} + /** * A result for a call that never reached, or never got past, Atlas. * @@ -154,7 +291,7 @@ export function errorResult( applied_before_stop: appliedBeforeStop(approvals), permission_relay: relayUsed ? { used: true, reason: "", detail: RELAY_ON_DETAIL } - : { used: false, reason: "no_human", detail: RELAY_OFF_DETAIL }, + : { used: false, reason: "no_human", detail: RELAY_OFF_DETAILS.no_human }, atlas_response: null, error: message, }; diff --git a/src/tools/ask-browserstack/types.ts b/src/tools/ask-browserstack/types.ts index f3da7f2..21845a4 100644 --- a/src/tools/ask-browserstack/types.ts +++ b/src/tools/ask-browserstack/types.ts @@ -38,7 +38,17 @@ export type Decision = "allow" | "deny"; * CONTRACT §2/§7. Advisory ONLY — `decision` alone decides whether the action proceeds. * It exists so a result can distinguish "a human said no" from "no human was there". */ -export type DecisionReason = "" | "declined" | "cancelled" | "no_human" | "timeout"; +export type DecisionReason = + | "" + | "declined" + | "cancelled" + | "no_human" + | "timeout" + // v1.1 §C enumerated "error" — the relay broke before or during the ask, so no human ever + // answered. This side never puts it ON THE WIRE: an unexpected relay failure answers HTTP + // 500, which Atlas's fail-closed rule already reads as a deny and records as `error_relay`. + // It appears only in `ApprovalRecord.reason`, where the caller can see what happened. + | "error"; /** CONTRACT §2 — what we answer the still-open callback request with. */ export interface PermissionDecision { @@ -88,10 +98,19 @@ export interface AskResult { approvals: ApprovalRecord[]; needs_approval: unknown[]; applied_before_stop: boolean; - /** Why a write may have been refused, in the reason vocabulary of CONTRACT §7. */ + /** + * Why a write may have been refused. + * + * `reason` is ATLAS'S OWN when it reported one (CONTRACT v1.1 §D: `"" | disabled | + * host_not_allowed | malformed`), and ours — `no_human` (§7's last row) — when the client + * could not be prompted at all, which Atlas never learns about because we omit the block. + * Typed as a plain string rather than a union because an Atlas newer than this build may + * name a reason this one has never heard of, and an unrecognised reason must degrade to a + * sentence, not throw. + */ permission_relay: { used: boolean; - reason: DecisionReason; + reason: string; detail: string; }; /** @@ -102,6 +121,12 @@ export interface AskResult { * side would otherwise silently become `null` here rather than reaching the caller. */ atlas_response: unknown; - /** Only set when the call itself failed before or during egress. */ + /** + * Why the call failed, when it did. + * + * Ours when egress never completed; otherwise Atlas's own `error` string, which its + * `public()` includes ONLY when non-empty (v1.1 §B). Lifted out of `atlas_response` so a + * caller reading the top level is told why rather than having to go looking. + */ error?: string; } diff --git a/tests/tools/askBrowserstack.test.ts b/tests/tools/askBrowserstack.test.ts index aeb7990..26f9831 100644 --- a/tests/tools/askBrowserstack.test.ts +++ b/tests/tools/askBrowserstack.test.ts @@ -12,6 +12,7 @@ import { buildResult, decide, deriveStatus, + elicitationMessage, } from "../../src/tools/ask-browserstack/relay.js"; import { ApprovalRecord } from "../../src/tools/ask-browserstack/types.js"; @@ -289,3 +290,152 @@ describe("host resolution", () => { expect(atlasBaseUrl()).toBe("https://atlas.example"); }); }); + +describe("the verified /agent response shape — CONTRACT v1.1 §B", () => { + it("reads an ABSENT needs_approval as empty, because public() omits it when empty", () => { + // The trap: Atlas never sends `[]`, it sends nothing at all. Same for narration, + // artifacts, error, cost_breach and usage. + const result = buildResult( + { status: 200, body: { ok: true, status: "ok", answer: "done", steps: [] } }, + [], true, + ); + expect(result.needs_approval).toEqual([]); + expect(result.status).toBe("ok"); + expect(result.ok).toBe(true); + }); + + it("passes through every status in the verified vocabulary", () => { + for (const status of ["ok", "error", "blocked", "rate_limited"]) { + expect(deriveStatus({ status: 200, body: { status } }, [], [])).toBe(status); + } + }); + + it("ignores a status outside the vocabulary and derives one instead", () => { + // `interrupted` is in the dataclass comment but is not emitted on this path. + expect(deriveStatus({ status: 200, body: { status: "interrupted" } }, [], [])).toBe("ok"); + expect(deriveStatus( + { status: 200, body: { status: "interrupted", needs_approval: ["x"] } }, [], ["x"], + )).toBe("blocked"); + }); + + it("lifts Atlas's own error string to the top level rather than burying it", () => { + const result = buildResult( + { status: 200, body: { ok: true, status: "error", answer: "", error: "the run died" } }, + [], true, + ); + expect(result.status).toBe("error"); + expect(result.error).toBe("the run died"); + }); + + it("has no error key when Atlas reported none", () => { + const result = buildResult({ status: 200, body: { status: "ok", answer: "" } }, [], true); + expect("error" in result).toBe(false); + }); +}); + +describe("Atlas's permission_relay verdict — CONTRACT v1.1 §D", () => { + function relayOf(permission_relay: unknown, relayUsed = true) { + return buildResult( + { status: 200, body: { status: "blocked", answer: "", permission_relay } }, + [], relayUsed, + ).permission_relay; + } + + it("prefers Atlas's used/reason over anything this side inferred", () => { + // We offered the channel and would have inferred used: true. Atlas knows better. + expect(relayOf({ used: false, reason: "disabled" })).toMatchObject({ + used: false, reason: "disabled", + }); + expect(relayOf({ used: true, reason: "" })).toMatchObject({ used: true, reason: "" }); + }); + + it("says plainly that a disabled relay is NOT anyone declining", () => { + // The distinction a user cannot make for themselves, and will retry forever without. + const relay = relayOf({ used: false, reason: "disabled" }); + expect(relay.detail).toMatch(/NOBODY DECLINED THIS/); + expect(relay.detail).toMatch(/administrator turns the relay on/); + expect(relay.detail).not.toMatch(/does not support MCP elicitation/); + }); + + it("gives host_not_allowed and malformed their own distinct sentences", () => { + const host = relayOf({ used: false, reason: "host_not_allowed" }).detail; + const malformed = relayOf({ used: false, reason: "malformed" }).detail; + expect(host).toMatch(/allowed-callback list/); + expect(host).toMatch(/same host/); + expect(malformed).toMatch(/bug on this side/); + expect(host).not.toBe(malformed); + }); + + it("degrades an unrecognised reason to a sentence instead of crashing", () => { + // Forward compatibility: an Atlas newer than this build may name a reason we have + // never heard of, and a result is not the place to throw. + const relay = relayOf({ used: false, reason: "quota_exhausted" }); + expect(relay.used).toBe(false); + expect(relay.reason).toBe("quota_exhausted"); + expect(relay.detail).toMatch(/does not recognise/); + expect(relay.detail).toMatch(/quota_exhausted/); + }); + + it("bounds an absurd reason before putting it in a sentence a human reads", () => { + const relay = relayOf({ used: false, reason: "x".repeat(5000) }); + expect(relay.detail.length).toBeLessThan(500); + }); + + it("falls back to the inferred verdict when Atlas sends none (an Atlas older than v1.1)", () => { + expect(relayOf(undefined)).toEqual({ + used: true, reason: "", detail: expect.stringContaining("asked before each change"), + }); + }); + + it("treats an unreadable verdict as no verdict rather than half-trusting it", () => { + expect(relayOf({ reason: "disabled" }).used).toBe(true); // no `used` boolean + expect(relayOf("disabled").used).toBe(true); + expect(relayOf([{ used: false }]).used).toBe(true); + }); + + it("keeps no_human ours: Atlas is never told the client cannot be prompted", () => { + // We omitted the block, so anything Atlas says about a relay cannot be about this run. + const relay = relayOf({ used: true, reason: "" }, false); + expect(relay).toEqual({ + used: false, + reason: "no_human", + detail: expect.stringContaining("does not support MCP elicitation"), + }); + }); +}); + +describe("the elicitation message — CONTRACT v1.1 §G", () => { + it("names the product and leaves the description untouched", () => { + const message = elicitationMessage("tm", "Create folder \"Regression\"."); + expect(message).toBe( + "BrowserStack AI (Test Management) needs your approval to continue:\n\n" + + "Create folder \"Regression\".", + ); + // Verbatim: the human must approve what the model actually said. + expect(message.endsWith("Create folder \"Regression\".")).toBe(true); + }); + + it("labels every product the tool accepts", () => { + expect(elicitationMessage("a11y", "x")).toMatch(/\(Accessibility\)/); + expect(elicitationMessage("tra", "x")).toMatch(/\(Test Reporting & Analytics\)/); + }); + + it("carries a product it has no label for rather than dropping it", () => { + expect(elicitationMessage("newproduct", "x")).toMatch(/\(newproduct\)/); + }); + + it("omits the brackets entirely rather than showing an empty pair", () => { + expect(elicitationMessage("", "x")).toBe( + "BrowserStack AI needs your approval to continue:\n\nx", + ); + }); + + it("still reads as a prompt when Atlas withheld a route-shaped description", () => { + // v1.1 §A: a description that trips Atlas's route guard is replaced, not dropped, so + // what arrives is a sentence — and must not look like a bug once framed. + const withheld = "[withheld: this description referenced an internal route]"; + expect(elicitationMessage("tm", withheld)).toBe( + "BrowserStack AI (Test Management) needs your approval to continue:\n\n" + withheld, + ); + }); +}); diff --git a/tests/tools/askBrowserstackE2E.test.ts b/tests/tools/askBrowserstackE2E.test.ts index dba68f2..7520ade 100644 --- a/tests/tools/askBrowserstackE2E.test.ts +++ b/tests/tools/askBrowserstackE2E.test.ts @@ -137,10 +137,13 @@ describe("askBrowserstackAI, end to end through the server factory", () => { .toMatch(/^http:\/\/127\.0\.0\.1:\d+\/atlas-permission$/); expect(stub.calls[0].body.permission_relay.token).toHaveLength(64); - // 2. the prompt: Atlas's description verbatim, and a boolean confirm + // 2. the prompt: framed with the product, Atlas's description verbatim, boolean confirm expect(elicit).toHaveBeenCalledTimes(1); const request = elicit.mock.calls[0][0] as any; - expect(request.message).toBe("Create folder \"Regression\"."); + expect(request.message).toBe( + "BrowserStack AI (Test Management) needs your approval to continue:\n\n" + + "Create folder \"Regression\".", + ); expect(request.requestedSchema.properties.confirm.type).toBe("boolean"); expect(request.requestedSchema.required).toEqual(["confirm"]); // The inner rung of the timeout ladder, shorter than Atlas's 300s gate. @@ -270,6 +273,74 @@ describe("askBrowserstackAI, end to end through the server factory", () => { expect(payload.needs_approval).toEqual(["Move 40 test cases into it."]); }); + it("reports a server-side disabled relay as a configuration fact, not a refusal", async () => { + const server = await buildServer(); + const elicit = fakeClient(server.getInstance(), { elicitation: {} }, []); + // v1.1 §D: the block was supplied, `delegation.permission_relay` is "off", so Atlas + // ignored it and ran read-only. Nobody was ever asked. + const stub = atlas({ + payload: () => ({ + ok: true, status: "blocked", answer: "I could not create the folder.", steps: [], + needs_approval: ["Create folder \"Regression\"."], + permission_relay: { used: false, reason: "disabled" }, + }), + }); + + const { result, payload } = await call(server.getTools()); + + expect(stub.calls[0].body.permission_relay).toBeDefined(); // we DID offer it + expect(elicit).not.toHaveBeenCalled(); // Atlas never called back + expect(payload.permission_relay).toEqual({ + used: false, + reason: "disabled", + detail: expect.stringContaining("NOBODY DECLINED THIS"), + }); + expect(payload.approvals).toEqual([]); + expect(payload.applied_before_stop).toBe(false); + // A refusal is not a tool failure, and rendering it as one invites the retry loop + // these distinct reasons exist to prevent. + expect(result.isError).toBeUndefined(); + }); + + it("treats an absent needs_approval as empty, end to end", async () => { + const server = await buildServer(); + fakeClient(server.getInstance(), { elicitation: {} }, [ + { action: "accept", content: { confirm: true } }, + ]); + atlas({ + asks: [{ perm_id: PERM_A, description: "Create folder." }], + // Exactly what public() emits when nothing needed approval: the key is absent. + payload: () => ({ + ok: true, status: "ok", answer: "Created folder 12.", steps: [], + permission_relay: { used: true, reason: "" }, + }), + }); + + const { result, payload } = await call(server.getTools()); + expect(payload.needs_approval).toEqual([]); + expect(payload.status).toBe("ok"); + expect(payload.permission_relay).toEqual({ + used: true, reason: "", detail: expect.stringContaining("asked before each change"), + }); + expect(result.isError).toBeUndefined(); + }); + + it("marks a genuine failure as an error but a refusal as a result", async () => { + const server = await buildServer(); + fakeClient(server.getInstance(), { elicitation: {} }, [{ action: "decline" }]); + atlas({ + asks: [{ perm_id: PERM_A, description: "Delete the sprint." }], + payload: () => ({ + ok: true, status: "rate_limited", answer: "", error: "too many runs", steps: [], + }), + }); + + const { result, payload } = await call(server.getTools()); + expect(payload.status).toBe("rate_limited"); + expect(payload.error).toBe("too many runs"); + expect(result.isError).toBe(true); + }); + it("tears the listener down once the call ends, even when the call failed", async () => { const server = await buildServer(); fakeClient(server.getInstance(), { elicitation: {} }, []); From 2628aa46ec3d27bc0b4e2275f9136ea0efc4a6ec Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Mon, 24 Aug 2026 15:39:43 +0530 Subject: [PATCH 08/31] Authenticate /agent the way /agent actually authenticates MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Atlas's delegation route accepts exactly two credentials, both in the Authorization header: the shared delegation token, or a BrowserStack central JWT. There is no Api-Token path. That header is right for the product APIs and was carried over here by analogy, so as built every call would have returned 401 before the gate, the relay or the model were reached. So /agent now sends Bearer , from its own env var resolved per call with the same precedence as the host — a preprod deployment must not be able to fall back to a token meant for somewhere else. It refuses by name when unset, naming the variable and never a value, and the token appears in no log line, no error message and no result. Api-Token is removed rather than left as harmless clutter. Atlas never reads it on this route, and it carries the user's access key, so sending it pushed a secret across a trust boundary to an endpoint with no use for it and into every request log on the way. The access key now does not leave this process on this route at all; a call no longer needs one to succeed, and the header key set is asserted exactly so it cannot creep back. The shared token authenticates the caller but not the principal, so Atlas reads the acting user from the body. user_id carries the configured username, omitted entirely rather than sent empty. A caller can claim any user_id on this path; that is Atlas's documented design for the shared-token route, not a hole to plug here. A rejected credential and a refused action are unrelated problems, and Atlas answers a bad bearer with a bare {"detail": "unauthorized"} and no error string of its own. A 401 therefore gets its own sentence, leading with the fact that nobody declined anything, so nobody goes hunting for a human who said no when the real answer is that this server never got through the door. --- src/tools/ask-browserstack/config.ts | 35 ++++++ src/tools/ask-browserstack/egress.ts | 32 +++++- src/tools/ask-browserstack/register.ts | 19 ++- src/tools/ask-browserstack/relay.ts | 22 +++- src/tools/ask-browserstack/types.ts | 12 ++ tests/tools/askBrowserstack.test.ts | 54 ++++++++- tests/tools/askBrowserstackE2E.test.ts | 153 ++++++++++++++++++++++++- 7 files changed, 310 insertions(+), 17 deletions(-) diff --git a/src/tools/ask-browserstack/config.ts b/src/tools/ask-browserstack/config.ts index 69cc2eb..55f6542 100644 --- a/src/tools/ask-browserstack/config.ts +++ b/src/tools/ask-browserstack/config.ts @@ -76,3 +76,38 @@ export function atlasBaseUrl(): string { export function agentUrl(): string { return `${atlasBaseUrl()}/agent`; } + +/** + * The shared delegation token `POST /agent` authenticates with (CONTRACT v1.2 §I). + * + * `/agent` accepts exactly two credentials, both in `Authorization`: this shared token, which + * authenticates the CALLER only, or a BrowserStack central JWT, which also attests the acting + * user. There is no `Api-Token` path on this route. The shared token is what + * `authenticate()`'s own docstring describes for a backend caller like an MCP server, and the + * JWT path would mean minting a credential, which this work does not do. + * + * Same precedence rungs as the host, for the same reason: a deployment pointing at preprod + * must not be able to fall back to a token meant for somewhere else. + * + * THIS VALUE IS A SECRET. It is never logged, never returned in a result, and never named in + * an error message — only the env var that should hold it is. + */ +export function atlasToken(): string { + const explicit = process.env.ASK_BROWSERSTACK_ATLAS_TOKEN; + if (explicit && explicit.trim()) return explicit.trim(); + + const environment = selectedEnvironment(); + if (environment) { + const suffixed = + process.env[`ASK_BROWSERSTACK_ATLAS_TOKEN_${environment.toUpperCase()}`]; + if (suffixed && suffixed.trim()) return suffixed.trim(); + } + + throw new AskError( + "BrowserStack AI is not authenticated: set ASK_BROWSERSTACK_ATLAS_TOKEN" + + (environment + ? ` or ASK_BROWSERSTACK_ATLAS_TOKEN_${environment.toUpperCase()}` + : "") + + " to the shared delegation token", + ); +} diff --git a/src/tools/ask-browserstack/egress.ts b/src/tools/ask-browserstack/egress.ts index 1b2f976..609e021 100644 --- a/src/tools/ask-browserstack/egress.ts +++ b/src/tools/ask-browserstack/egress.ts @@ -5,17 +5,37 @@ * not exist yet, so every test substitutes this rather than reaching a live service — the * same role `RegistryDeps.transport` plays for the capability registry. * - * Auth is the CALLER'S OWN CREDENTIALS, forwarded as `Api-Token: :`. - * Nothing is minted here; `authHeaders` is reused rather than reimplemented so the two - * surfaces cannot drift on a security-relevant header. + * AUTH HERE IS NOT THE PRODUCT-API AUTH. `/agent` accepts exactly two credentials, both in + * `Authorization`: the shared delegation token or a BrowserStack central JWT. There is no + * `Api-Token` path on this route (CONTRACT v1.2 §I), so sending one would not merely be + * useless — it would push the user's `access_key` across a trust boundary to an endpoint + * that has no use for it, and into every request log on the way. The capability registry's + * `authHeaders` remains right for PRODUCT calls; it is simply not the header set for this + * one, and is deliberately not imported here. */ -import { authHeaders, Credentials } from "../capability-registry/egress.js"; import { AGENT_TIMEOUT_MS } from "./config.js"; import { AgentRequest } from "./types.js"; -export { authHeaders }; -export type { Credentials }; +export interface Credentials { + username: string; + accessKey: string; +} + +/** + * The complete header set for `POST /agent` (CONTRACT v1.2 §4). Three headers, no more. + * + * The token is a secret and appears nowhere else: not in a log line, not in a result, not in + * an error message. + */ +export function agentHeaders(token: string): Record { + return { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + // Attribution, so the downstream service can see the call came from an agent. + "request-source": "ai-chatbot", + }; +} export interface AgentResponse { status: number; diff --git a/src/tools/ask-browserstack/register.ts b/src/tools/ask-browserstack/register.ts index 21cda11..2deb618 100644 --- a/src/tools/ask-browserstack/register.ts +++ b/src/tools/ask-browserstack/register.ts @@ -39,12 +39,13 @@ import { AskError, ELICITATION_TIMEOUT_MS, agentUrl, + atlasToken, isEnabled, } from "./config.js"; import { AgentTransport, Credentials, - authHeaders, + agentHeaders, fetchAgentTransport, } from "./egress.js"; import { @@ -64,9 +65,15 @@ import { export interface AskDeps { /** Resolved per call: a deployment's host is configuration, not a constructor argument. */ agentUrl: () => string; + /** The shared delegation token, resolved per call and for the same reason. A secret. */ + atlasToken: () => string; /** * Read per call, not captured: the remote server rebuilds config per session, so a * captured credential would outlive the session it belongs to. + * + * ONLY `username` is used, and only to attribute the run (`user_id`). The access key does + * NOT leave this process on this route: `/agent` has no use for it, so sending it would be + * exposure for nothing. Product calls are the place for `authHeaders`. */ credentialsFor: () => Credentials; transport?: AgentTransport; @@ -225,9 +232,16 @@ export function addAskBrowserstackAITool( try { const url = deps.agentUrl(); - const headers = authHeaders(deps.credentialsFor()); + const headers = agentHeaders(deps.atlasToken()); const body: AgentRequest = { task: query, product }; + // Attribution. The shared token authenticates the CALLER only, leaving + // `principal_verified=false`, so Atlas takes the acting user from the body — without + // it the run is unattributed, per-user limits cannot apply and the audit row cannot + // name who asked. Omitted ENTIRELY when unset, never sent as "". + const username = (deps.credentialsFor().username || "").trim(); + if (username) body.user_id = username; + if (canElicit) { listener = await startListener((ask) => relayOneAsk(server, ask, approvals), @@ -286,6 +300,7 @@ export function addAskBrowserstackAIToolFromConfig( // Both resolved per call. An unconfigured host surfaces as a named error from the // tool rather than as a missing tool, so the cause is visible to whoever hits it. agentUrl, + atlasToken, credentialsFor: () => ({ username: config["browserstack-username"], accessKey: config["browserstack-access-key"], diff --git a/src/tools/ask-browserstack/relay.ts b/src/tools/ask-browserstack/relay.ts index b7fcf0a..1ae10ac 100644 --- a/src/tools/ask-browserstack/relay.ts +++ b/src/tools/ask-browserstack/relay.ts @@ -255,17 +255,35 @@ export function buildResult( }; } +/** + * A rejected credential and a refused action are unrelated problems, and a result that lets + * them read alike sends someone hunting for a human who said no when the real answer is that + * this server never got through the door. + * + * Atlas answers a bad `Authorization` with `401 {"detail": "unauthorized"}` — no `error` + * string of its own — so without this the caller would see a bare "error" and nothing else. + * A denial, by contrast, is `status: "blocked"` with a populated `approvals` trail. + * + * The token itself is NOT named here, only the variable that should hold it. + */ +export const UNAUTHENTICATED_DETAIL = + "BrowserStack AI rejected this server's credentials (HTTP 401). NOBODY DECLINED " + + "ANYTHING — the request never reached the agent, so no permission was sought and nothing " + + "was refused. The shared delegation token in ASK_BROWSERSTACK_ATLAS_TOKEN is missing, " + + "wrong, or not the one this environment expects."; + /** * The `error` field, from whichever side has one. * - * Ours when there was no response to speak for itself; otherwise Atlas's own string, which - * `public()` includes ONLY when non-empty (v1.1 §B). + * Ours when there was no response to speak for itself, or when the response was a 401; + * otherwise Atlas's own string, which `public()` includes ONLY when non-empty (v1.1 §B). */ function atlasError( response: AgentResponse, payload: Record, ): { error?: string } { if (response.status === 0 && response.error) return { error: response.error }; + if (response.status === 401) return { error: UNAUTHENTICATED_DETAIL }; const reported = payload.error; return typeof reported === "string" && reported ? { error: reported } : {}; } diff --git a/src/tools/ask-browserstack/types.ts b/src/tools/ask-browserstack/types.ts index 21845a4..7d49e03 100644 --- a/src/tools/ask-browserstack/types.ts +++ b/src/tools/ask-browserstack/types.ts @@ -73,6 +73,18 @@ export interface PermissionRelay { export interface AgentRequest { task: string; product: string; + /** + * Who the run is for (CONTRACT v1.2 §3). + * + * The shared delegation token authenticates the caller but not the principal + * (`principal_verified=false`), so Atlas reads the acting user from here. Omitted entirely + * when no username is configured — never sent as `""`. + * + * Note the asymmetry and do not try to close it: on this path a caller can CLAIM any + * `user_id`. That is Atlas's documented design for the shared-token route; a signed + * principal requires the central-JWT path, which is out of scope. + */ + user_id?: string; permission_relay?: PermissionRelay; } diff --git a/tests/tools/askBrowserstack.test.ts b/tests/tools/askBrowserstack.test.ts index 26f9831..2dd9c01 100644 --- a/tests/tools/askBrowserstack.test.ts +++ b/tests/tools/askBrowserstack.test.ts @@ -6,7 +6,10 @@ import { parseAsk, startCallbackListener, } from "../../src/tools/ask-browserstack/callback.js"; -import { AskError, atlasBaseUrl } from "../../src/tools/ask-browserstack/config.js"; +import { + AskError, atlasBaseUrl, atlasToken, +} from "../../src/tools/ask-browserstack/config.js"; +import { agentHeaders } from "../../src/tools/ask-browserstack/egress.js"; import { appliedBeforeStop, buildResult, @@ -439,3 +442,52 @@ describe("the elicitation message — CONTRACT v1.1 §G", () => { ); }); }); + +describe("POST /agent headers — CONTRACT v1.2 §4", () => { + it("is exactly three headers, and Api-Token is not one of them", () => { + // /agent has no Api-Token path, and that header carries the user's access key. Asserting + // the exact key set is what stops it being reintroduced by a helpful future edit. + expect(agentHeaders("shared-delegation-token")).toEqual({ + Authorization: "Bearer shared-delegation-token", + "Content-Type": "application/json", + "request-source": "ai-chatbot", + }); + }); +}); + +describe("the delegation token", () => { + const saved = { ...process.env }; + + afterEach(() => { + process.env = { ...saved }; + }); + + it("refuses by name, naming the variable and never a value", () => { + delete process.env.ASK_BROWSERSTACK_ATLAS_TOKEN; + delete process.env.ASK_BROWSERSTACK_ENV; + delete process.env.CAPABILITY_REGISTRY_ENV; + expect(() => atlasToken()).toThrow(AskError); + expect(() => atlasToken()).toThrow(/ASK_BROWSERSTACK_ATLAS_TOKEN/); + }); + + it("treats a blank token as unset rather than sending `Bearer `", () => { + process.env.ASK_BROWSERSTACK_ATLAS_TOKEN = " "; + delete process.env.ASK_BROWSERSTACK_ENV; + delete process.env.CAPABILITY_REGISTRY_ENV; + expect(() => atlasToken()).toThrow(AskError); + }); + + it("takes the environment's token when one is named, and trims it", () => { + delete process.env.ASK_BROWSERSTACK_ATLAS_TOKEN; + process.env.ASK_BROWSERSTACK_ENV = "preprod"; + process.env.ASK_BROWSERSTACK_ATLAS_TOKEN_PREPROD = " preprod-token "; + expect(atlasToken()).toBe("preprod-token"); + }); + + it("lets an explicit token win", () => { + process.env.ASK_BROWSERSTACK_ENV = "preprod"; + process.env.ASK_BROWSERSTACK_ATLAS_TOKEN_PREPROD = "preprod-token"; + process.env.ASK_BROWSERSTACK_ATLAS_TOKEN = "explicit-token"; + expect(atlasToken()).toBe("explicit-token"); + }); +}); diff --git a/tests/tools/askBrowserstackE2E.test.ts b/tests/tools/askBrowserstackE2E.test.ts index 7520ade..7ab0ff4 100644 --- a/tests/tools/askBrowserstackE2E.test.ts +++ b/tests/tools/askBrowserstackE2E.test.ts @@ -2,6 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { AskError } from "../../src/tools/ask-browserstack/config.js"; import { addAskBrowserstackAITool } from "../../src/tools/ask-browserstack/register.js"; /** Captured before anything stubs the global, so the loopback hop stays real. */ @@ -94,6 +95,7 @@ async function call(tools: Record, args = { product: "tm", query: " describe("askBrowserstackAI, end to end through the server factory", () => { beforeEach(() => { process.env.ASK_BROWSERSTACK_ATLAS_URL = "https://atlas.example"; + process.env.ASK_BROWSERSTACK_ATLAS_TOKEN = "shared-delegation-token"; delete process.env.ASK_BROWSERSTACK_DISABLED; delete process.env.ASK_BROWSERSTACK_ENV; vi.resetModules(); @@ -101,6 +103,7 @@ describe("askBrowserstackAI, end to end through the server factory", () => { afterEach(() => { delete process.env.ASK_BROWSERSTACK_ATLAS_URL; + delete process.env.ASK_BROWSERSTACK_ATLAS_TOKEN; vi.unstubAllGlobals(); vi.restoreAllMocks(); }); @@ -128,11 +131,17 @@ describe("askBrowserstackAI, end to end through the server factory", () => { const { payload } = await call(server.getTools()); - // 1. the request: relay offered, on loopback, with a per-run token + // 1. the request: authenticated with the SHARED DELEGATION TOKEN, attributed, relay + // offered on loopback with a per-run token expect(stub.calls[0].url).toBe("https://atlas.example/agent"); - expect(stub.calls[0].headers["Api-Token"]).toBe("ing_Xx:SECRET"); + expect(stub.calls[0].headers.Authorization).toBe("Bearer shared-delegation-token"); + // The EXACT key set. /agent has no Api-Token path, and that header carries the user's + // access key — this assertion is what stops it creeping back in. + expect(Object.keys(stub.calls[0].headers).sort()) + .toEqual(["Authorization", "Content-Type", "request-source"]); expect(stub.calls[0].body.task).toBe("make a folder"); expect(stub.calls[0].body.product).toBe("tm"); + expect(stub.calls[0].body.user_id).toBe("ing_Xx"); expect(stub.calls[0].body.permission_relay.callback_url) .toMatch(/^http:\/\/127\.0\.0\.1:\d+\/atlas-permission$/); expect(stub.calls[0].body.permission_relay.token).toHaveLength(64); @@ -371,7 +380,8 @@ describe("askBrowserstackAI, end to end through the server factory", () => { // Absence is what selects Atlas's read-only HeadlessGate — not an empty object. expect("permission_relay" in stub.calls[0].body).toBe(false); - expect(Object.keys(stub.calls[0].body).sort()).toEqual(["product", "task"]); + expect(Object.keys(stub.calls[0].body).sort()) + .toEqual(["product", "task", "user_id"]); expect(elicit).not.toHaveBeenCalled(); expect(payload.status).toBe("blocked"); @@ -409,6 +419,111 @@ describe("askBrowserstackAI, end to end through the server factory", () => { }); }); + describe("POST /agent authentication — CONTRACT v1.2", () => { + it("omits user_id entirely, never as \"\", when no username is configured", async () => { + const { BrowserStackMcpServer } = await import("../../src/server-factory.js"); + const server = new BrowserStackMcpServer({ + "browserstack-username": "", + "browserstack-access-key": "", + } as any); + fakeClient(server.getInstance(), { roots: {} }, []); + const stub = atlas({}); + + await call(server.getTools()); + expect("user_id" in stub.calls[0].body).toBe(false); + expect(Object.keys(stub.calls[0].body).sort()).toEqual(["product", "task"]); + }); + + it("refuses by name when the token is unset, and never leaks it", async () => { + delete process.env.ASK_BROWSERSTACK_ATLAS_TOKEN; + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + const server = await buildServer(); + fakeClient(server.getInstance(), { elicitation: {} }, []); + + const { result, payload } = await call(server.getTools()); + expect(result.isError).toBe(true); + expect(payload.error).toMatch(/ASK_BROWSERSTACK_ATLAS_TOKEN/); + expect(fetchSpy).not.toHaveBeenCalled(); + }); + + it("never puts the token in the result, on success or on failure", async () => { + const server = await buildServer(); + fakeClient(server.getInstance(), { elicitation: {} }, [ + { action: "accept", content: { confirm: true } }, + ]); + atlas({ asks: [{ perm_id: PERM_A, description: "Create folder." }] }); + + const { result } = await call(server.getTools()); + // The whole serialised result, not just the fields we happen to check. + expect(result.content[0].text).not.toContain("shared-delegation-token"); + }); + + it("reads a 401 as rejected credentials, not as anyone declining", async () => { + const server = await buildServer(); + const elicit = fakeClient(server.getInstance(), { elicitation: {} }, []); + vi.stubGlobal("fetch", async () => ({ + status: 401, + headers: { get: () => "application/json" }, + // Exactly what Atlas answers a bad bearer with: no `error` string of its own. + json: async () => ({ detail: "unauthorized" }), + })); + + const { result, payload } = await call(server.getTools()); + + expect(payload.status).toBe("error"); + expect(result.isError).toBe(true); + expect(payload.error).toMatch(/rejected this server's credentials/); + expect(payload.error).toMatch(/NOBODY DECLINED ANYTHING/); + expect(payload.error).toMatch(/ASK_BROWSERSTACK_ATLAS_TOKEN/); + // Nothing was ever asked, so nothing can look like a refusal. + expect(elicit).not.toHaveBeenCalled(); + expect(payload.approvals).toEqual([]); + expect(payload.applied_before_stop).toBe(false); + }); + + it("keeps a 401 and a permission denial from reading alike", async () => { + const server = await buildServer(); + fakeClient(server.getInstance(), { elicitation: {} }, [{ action: "decline" }]); + atlas({ + asks: [{ perm_id: PERM_A, description: "Delete the sprint." }], + payload: () => ({ + ok: true, status: "blocked", answer: "", steps: [], + needs_approval: ["Delete the sprint."], + permission_relay: { used: true, reason: "" }, + }), + }); + + const { result, payload } = await call(server.getTools()); + // A denial: not an error, and the trail says who said no. + expect(payload.status).toBe("blocked"); + expect(result.isError).toBeUndefined(); + expect(payload.error).toBeUndefined(); + expect(payload.approvals[0]).toMatchObject({ decision: "deny", reason: "declined" }); + }); + + it("lets the named environment pick the token, so preprod cannot borrow prod's", async () => { + delete process.env.ASK_BROWSERSTACK_ATLAS_TOKEN; + process.env.ASK_BROWSERSTACK_ENV = "preprod"; + process.env.ASK_BROWSERSTACK_ATLAS_URL_PREPROD = "https://atlas-preprod.example"; + process.env.ASK_BROWSERSTACK_ATLAS_TOKEN_PREPROD = "preprod-token"; + delete process.env.ASK_BROWSERSTACK_ATLAS_URL; + try { + const server = await buildServer(); + fakeClient(server.getInstance(), { roots: {} }, []); + const stub = atlas({}); + + await call(server.getTools()); + expect(stub.calls[0].url).toBe("https://atlas-preprod.example/agent"); + expect(stub.calls[0].headers.Authorization).toBe("Bearer preprod-token"); + } finally { + delete process.env.ASK_BROWSERSTACK_ENV; + delete process.env.ASK_BROWSERSTACK_ATLAS_URL_PREPROD; + delete process.env.ASK_BROWSERSTACK_ATLAS_TOKEN_PREPROD; + } + }); + }); + it("refuses by name when no host is configured, without calling anything", async () => { delete process.env.ASK_BROWSERSTACK_ATLAS_URL; const fetchSpy = vi.fn(); @@ -426,28 +541,54 @@ describe("askBrowserstackAI, end to end through the server factory", () => { describe("askBrowserstackAI, against the injected seam", () => { afterEach(() => vi.restoreAllMocks()); - it("refuses rather than calling Atlas unauthenticated", async () => { + it("refuses rather than calling Atlas unauthenticated, and never names the token", async () => { const mcp = new McpServer({ name: "t", version: "0" }); const transport = vi.fn(); const tools = addAskBrowserstackAITool(mcp, { agentUrl: () => "https://atlas.example/agent", - credentialsFor: () => ({ username: "u", accessKey: "" }), + atlasToken: () => { + throw new AskError( + "BrowserStack AI is not authenticated: set ASK_BROWSERSTACK_ATLAS_TOKEN to the " + + "shared delegation token", + ); + }, + credentialsFor: () => ({ username: "u", accessKey: "k" }), transport: transport as never, startListener: (async () => ({ url: "x", token: "y", close: async () => {} })) as never, }); const { payload } = await call(tools); expect(payload.ok).toBe(false); - expect(payload.error).toMatch(/not authenticated/); + expect(payload.error).toMatch(/ASK_BROWSERSTACK_ATLAS_TOKEN/); expect(transport).not.toHaveBeenCalled(); }); + it("does not need the user's access key to reach /agent", async () => { + // It is not sent on this route, so its absence must not refuse the call the way the + // product-API path would. + const mcp = new McpServer({ name: "t", version: "0" }); + const transport = vi.fn(async () => ({ status: 200, body: { status: "ok", answer: "" } })); + const tools = addAskBrowserstackAITool(mcp, { + agentUrl: () => "https://atlas.example/agent", + atlasToken: () => "shared-delegation-token", + credentialsFor: () => ({ username: "ing_Xx", accessKey: "" }), + transport: transport as never, + startListener: (async () => ({ url: "x", token: "y", close: async () => {} })) as never, + }); + + const { payload } = await call(tools); + expect(payload.ok).toBe(true); + expect(transport).toHaveBeenCalledTimes(1); + expect((transport.mock.calls[0] as any)[2].user_id).toBe("ing_Xx"); + }); + it("closes the listener even when the transport throws", async () => { const mcp = new McpServer({ name: "t", version: "0" }); vi.spyOn(mcp.server, "getClientCapabilities").mockReturnValue({ elicitation: {} } as never); const close = vi.fn(async () => {}); const tools = addAskBrowserstackAITool(mcp, { agentUrl: () => "https://atlas.example/agent", + atlasToken: () => "shared-delegation-token", credentialsFor: () => ({ username: "u", accessKey: "k" }), transport: (async () => { throw new Error("boom"); From 57be2d731b079837d141651d9b4934a185861ce1 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Mon, 24 Aug 2026 16:53:59 +0530 Subject: [PATCH 09/31] Stop claiming the approval channel was used when nothing was asked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Atlas omits its permission_relay verdict on every refusal that dies before the delegation layer — 401, 400 and 503 all answer with a bare {"detail": …} — and an unreachable Atlas has no body at all. The verdict reader treated a missing block as "an Atlas older than v1.1" and fell back to the optimistic reading, so a 401 came back asserting that BrowserStack asked before each change and the answers were in `approvals`, with `approvals` empty, alongside an `error` field saying nothing had ever been asked. Three fields in one payload contradicting each other, and zero prompts had appeared. That is the confusion the `disabled` sentence was written to prevent, one layer earlier: a reader who cannot tell "nobody was asked" from "somebody said no" retries forever. So a request that never reached the agent now says so in its own words, and says it outranks both of the other readings — including the no-elicitation one, because a client that cannot be prompted did not "run read-only" either when nothing ran at all. Pre-run refusals also carry `detail` and never `error`, so a 400 or a 503 used to arrive as status "error" with nothing whatsoever to act on. Atlas's detail is now surfaced with the status that carried it, bounded, since it comes off the wire and ends up in front of a person. The withheld-placeholder fixture asserted against a string Atlas does not emit. Runtime was never affected — the description passes through verbatim — but it is the one string here a test can assert on and be confidently wrong about, so it now uses the bytes collector.py actually builds. --- src/tools/ask-browserstack/register.ts | 4 +- src/tools/ask-browserstack/relay.ts | 94 +++++++++++++++++--- tests/tools/askBrowserstack.test.ts | 116 ++++++++++++++++++++++++- tests/tools/askBrowserstackE2E.test.ts | 47 ++++++++++ 4 files changed, 247 insertions(+), 14 deletions(-) diff --git a/src/tools/ask-browserstack/register.ts b/src/tools/ask-browserstack/register.ts index 2deb618..410ca89 100644 --- a/src/tools/ask-browserstack/register.ts +++ b/src/tools/ask-browserstack/register.ts @@ -266,7 +266,9 @@ export function addAskBrowserstackAITool( ? error.message : String(error); logger.error("askBrowserstackAI failed: %s", message); - return toResult(errorResult(message, approvals, canElicit)); + // No `canElicit` argument: the request never left this process, so whether the + // client could have been prompted is not what the reader needs to know. + return toResult(errorResult(message, approvals)); } finally { // Torn down here so it cannot leak across calls or survive an error, and awaited // so the port is released before the tool result is handed back. diff --git a/src/tools/ask-browserstack/relay.ts b/src/tools/ask-browserstack/relay.ts index 1ae10ac..e192819 100644 --- a/src/tools/ask-browserstack/relay.ts +++ b/src/tools/ask-browserstack/relay.ts @@ -51,6 +51,15 @@ export const RELAY_OFF_DETAILS: Record = { "BrowserStack could not use the approval channel this client offered and ran read-only. " + "That is a bug on this side, not something you did; everything in `needs_approval` was " + "refused because of it.", + + // The request never got as far as the agent. Distinct from `disabled` (the agent ran, with + // the relay switched off) and from a decline (someone was asked and said no), because the + // three call for completely different things from whoever reads them. + not_reached: + "NOTHING WAS ASKED AND NOTHING WAS REFUSED. BrowserStack rejected this request before " + + "the agent started, so no step ran, no prompt appeared, and nothing was changed. " + + "`error` says why. This is not a decision anyone made about your request — fix what " + + "`error` names and run it again.", }; /** Kept for anything still importing the old single constant. */ @@ -74,6 +83,29 @@ export function relayDetail(used: boolean, reason: string): string { return RELAY_OFF_DETAILS[reason] ?? unknownRelayDetail(false, reason); } +/** + * Did this request die before the agent ever started? + * + * Atlas omits its `permission_relay` verdict on refusals that never reach the delegation + * layer — 401 unauthorized, 400 bad body, 503 delegation-not-enabled all answer with a bare + * `{"detail": …}` — and a transport failure has no body at all. Read naively, "no verdict" + * looks identical to "an Atlas older than v1.1", and the optimistic fallback for THAT case + * then claims the channel was used and answers were collected when zero prompts appeared. + * + * Which is the same confusion the `disabled` sentence exists to prevent, one layer earlier: + * a caller who cannot tell "nobody was asked" from "somebody said no" retries forever. + */ +export function neverReachedAgent(response: AgentResponse): boolean { + // No response at all, or one that is not a success. + if (response.status === 0) return true; + if (response.status < 200 || response.status >= 300) return true; + // A success carrying a bare `detail` is not a delegation result either. + const payload = asRecord(response.body); + const looksLikeAResult = + "ok" in payload || "status" in payload || "answer" in payload; + return "detail" in payload && !looksLikeAResult; +} + /** * Atlas's own verdict on the relay (v1.1 §D), when it gave one. * @@ -211,7 +243,20 @@ export function deriveStatus( function relayVerdict( payload: Record, relayUsed: boolean, + reachedAgent: boolean, ): AskResult["permission_relay"] { + // FIRST, because it outranks both of the cases below. If the request never reached the + // agent then no channel was exercised whether or not one was offered, and saying the run + // went read-only (`no_human`) would be just as wrong as saying it was used: nothing ran at + // all. The client's inability to be prompted is not the actionable fact here and will + // surface on the next run, once whatever `error` names is fixed. + if (!reachedAgent) { + return { + used: false, + reason: "not_reached", + detail: RELAY_OFF_DETAILS.not_reached, + }; + } if (!relayUsed) { // CONTRACT §7's last row: no elicitation capability means the field was never sent. return { used: false, reason: "no_human", detail: RELAY_OFF_DETAILS.no_human }; @@ -240,6 +285,7 @@ export function buildResult( ? (payload.needs_approval as unknown[]) : []; const status = deriveStatus(response, approvals, needsApproval); + const reachedAgent = !neverReachedAgent(response); return { ok: status === "ok", @@ -249,7 +295,7 @@ export function buildResult( approvals, needs_approval: needsApproval, applied_before_stop: appliedBeforeStop(approvals), - permission_relay: relayVerdict(payload, relayUsed), + permission_relay: relayVerdict(payload, relayUsed, reachedAgent), atlas_response: response.body ?? null, ...atlasError(response, payload), }; @@ -275,8 +321,13 @@ export const UNAUTHENTICATED_DETAIL = /** * The `error` field, from whichever side has one. * - * Ours when there was no response to speak for itself, or when the response was a 401; - * otherwise Atlas's own string, which `public()` includes ONLY when non-empty (v1.1 §B). + * Ours when there was no response to speak for itself or the credentials were rejected; + * otherwise Atlas's own `error` string, which `public()` includes ONLY when non-empty + * (v1.1 §B) — and failing that, its bare `detail`. + * + * That last rung matters: Atlas's pre-run refusals (400 bad body, 503 delegation not + * enabled) carry `detail` and no `error`, so without it a caller got `status: "error"` and + * nothing whatsoever to act on. */ function atlasError( response: AgentResponse, @@ -284,8 +335,28 @@ function atlasError( ): { error?: string } { if (response.status === 0 && response.error) return { error: response.error }; if (response.status === 401) return { error: UNAUTHENTICATED_DETAIL }; + const reported = payload.error; - return typeof reported === "string" && reported ? { error: reported } : {}; + if (typeof reported === "string" && reported) return { error: reported }; + + const detail = payload.detail; + if (typeof detail === "string" && detail) { + // Bounded: this came off the wire and ends up in front of a person. + return { + error: + `BrowserStack AI refused this request before the agent started ` + + `(HTTP ${response.status}): ${JSON.stringify(detail.slice(0, 200))}.`, + }; + } + // A non-2xx with nothing to say for itself still beats a silent one. + if (response.status < 200 || response.status >= 300) { + return { + error: + `BrowserStack AI refused this request before the agent started ` + + `(HTTP ${response.status}).`, + }; + } + return {}; } /** @@ -295,11 +366,7 @@ function atlasError( * was granted is exactly the case where a caller most needs to know something may already * have been applied. */ -export function errorResult( - message: string, - approvals: ApprovalRecord[], - relayUsed: boolean, -): AskResult { +export function errorResult(message: string, approvals: ApprovalRecord[]): AskResult { return { ok: false, status: "error", @@ -307,9 +374,12 @@ export function errorResult( approvals, needs_approval: [], applied_before_stop: appliedBeforeStop(approvals), - permission_relay: relayUsed - ? { used: true, reason: "", detail: RELAY_ON_DETAIL } - : { used: false, reason: "no_human", detail: RELAY_OFF_DETAILS.no_human }, + // The request never left this process, so it certainly never reached the agent. + permission_relay: { + used: false, + reason: "not_reached", + detail: RELAY_OFF_DETAILS.not_reached, + }, atlas_response: null, error: message, }; diff --git a/tests/tools/askBrowserstack.test.ts b/tests/tools/askBrowserstack.test.ts index 2dd9c01..9113fef 100644 --- a/tests/tools/askBrowserstack.test.ts +++ b/tests/tools/askBrowserstack.test.ts @@ -14,8 +14,10 @@ import { appliedBeforeStop, buildResult, decide, + errorResult, deriveStatus, elicitationMessage, + neverReachedAgent, } from "../../src/tools/ask-browserstack/relay.js"; import { ApprovalRecord } from "../../src/tools/ask-browserstack/types.js"; @@ -436,7 +438,12 @@ describe("the elicitation message — CONTRACT v1.1 §G", () => { it("still reads as a prompt when Atlas withheld a route-shaped description", () => { // v1.1 §A: a description that trips Atlas's route guard is replaced, not dropped, so // what arrives is a sentence — and must not look like a bug once framed. - const withheld = "[withheld: this description referenced an internal route]"; + // + // THE REAL BYTES, from `collector.py:172-176` — `f"({kind} withheld: it referenced + // internal API detail)"` with kind="approval request" — and observed on the wire in the + // integration run. An invented placeholder is the one string in this feature a test can + // assert on and be confidently wrong about. + const withheld = "(approval request withheld: it referenced internal API detail)"; expect(elicitationMessage("tm", withheld)).toBe( "BrowserStack AI (Test Management) needs your approval to continue:\n\n" + withheld, ); @@ -491,3 +498,110 @@ describe("the delegation token", () => { expect(atlasToken()).toBe("explicit-token"); }); }); + +describe("pre-run refusals — the request never reached the agent (D3)", () => { + // Atlas omits its permission_relay verdict on every refusal that dies before the + // delegation layer, so "no verdict" alone cannot be read as "an old Atlas". + const REFUSALS: [string, { status: number; body: unknown; error?: string }][] = [ + ["401 unauthorized", { status: 401, body: { detail: "unauthorized" } }], + ["400 bad body", { status: 400, body: { detail: "task is required" } }], + ["503 delegation not enabled", { status: 503, body: { detail: "delegation is not enabled" } }], + ["unreachable", { status: 0, body: null, error: "BrowserStack AI could not be reached" }], + ]; + + it.each(REFUSALS)("detects %s", (_name, response) => { + expect(neverReachedAgent(response)).toBe(true); + }); + + it.each(REFUSALS)("never claims the channel was used on %s", (_name, response) => { + // The bug: `used: true` with an empty `approvals` and an `error` saying nothing was + // asked — three fields in one payload contradicting each other. + const relay = buildResult(response, [], true).permission_relay; + expect(relay.used).toBe(false); + expect(relay.reason).toBe("not_reached"); + expect(relay.detail).toMatch(/NOTHING WAS ASKED AND NOTHING WAS REFUSED/); + expect(relay.detail).not.toMatch(/asked before each change/); + }); + + it.each(REFUSALS)("always says why, on %s", (_name, response) => { + // A pre-run refusal with `status: "error"` and no `error` string leaves a caller with + // nothing to act on. 400 and 503 carry `detail`, never `error`. + const result = buildResult(response, [], true); + expect(result.status).toBe("error"); + expect(result.ok).toBe(false); + expect(typeof result.error).toBe("string"); + expect(result.error!.length).toBeGreaterThan(0); + expect(result.approvals).toEqual([]); + expect(result.applied_before_stop).toBe(false); + }); + + it("names the HTTP status and quotes Atlas's detail on a 400 and a 503", () => { + expect(buildResult({ status: 400, body: { detail: "task is required" } }, [], true).error) + .toBe('BrowserStack AI refused this request before the agent started (HTTP 400): "task is required".'); + expect(buildResult( + { status: 503, body: { detail: "delegation is not enabled" } }, [], true, + ).error).toMatch(/HTTP 503.*delegation is not enabled/); + }); + + it("bounds a detail string before putting it in front of a person", () => { + const result = buildResult({ status: 400, body: { detail: "x".repeat(5000) } }, [], true); + expect(result.error!.length).toBeLessThan(400); + }); + + it("still says something when a non-2xx has nothing to say for itself", () => { + expect(buildResult({ status: 502, body: null }, [], true).error) + .toBe("BrowserStack AI refused this request before the agent started (HTTP 502)."); + }); + + it("outranks no_human: a client that cannot elicit did not 'run read-only' either", () => { + // Saying the run went read-only would be as wrong as saying the channel was used — + // nothing ran at all. The elicitation gap resurfaces on the next run. + const relay = buildResult( + { status: 401, body: { detail: "unauthorized" } }, [], false, + ).permission_relay; + expect(relay.reason).toBe("not_reached"); + expect(relay.detail).not.toMatch(/does not support MCP elicitation/); + }); + + it("treats a 200 carrying a bare detail as not a delegation result", () => { + expect(neverReachedAgent({ status: 200, body: { detail: "nope" } })).toBe(true); + // ...but a real result that happens to carry a detail field is still a result. + expect(neverReachedAgent({ + status: 200, body: { ok: true, status: "ok", answer: "", detail: "fyi" }, + })).toBe(false); + }); + + it("leaves a genuine agent run alone", () => { + const response = { status: 200, body: { ok: true, status: "ok", answer: "done" } }; + expect(neverReachedAgent(response)).toBe(false); + expect(buildResult(response, [], true).permission_relay).toEqual({ + used: true, reason: "", detail: expect.stringContaining("asked before each change"), + }); + }); + + it("says the same thing when the call never left this process", () => { + // No token, no host, a transport that threw: nothing was sent, so nothing was asked. + const relay = errorResult("BrowserStack AI is not authenticated: set …", []).permission_relay; + expect(relay).toEqual({ + used: false, + reason: "not_reached", + detail: expect.stringContaining("NOTHING WAS ASKED AND NOTHING WAS REFUSED"), + }); + }); + + it("keeps a pre-run refusal from reading like a relay that was switched off", () => { + // `disabled` means the agent RAN with the relay off — retry after an admin acts. + // `not_reached` means it never ran at all — fix what `error` names and retry now. + const notReached = buildResult( + { status: 401, body: { detail: "unauthorized" } }, [], true, + ).permission_relay; + const disabled = buildResult( + { status: 200, body: { status: "blocked", permission_relay: { used: false, reason: "disabled" } } }, + [], true, + ).permission_relay; + expect(notReached.reason).not.toBe(disabled.reason); + expect(notReached.detail).not.toBe(disabled.detail); + expect(disabled.detail).toMatch(/administrator turns the relay on/); + expect(notReached.detail).toMatch(/run it again/); + }); +}); diff --git a/tests/tools/askBrowserstackE2E.test.ts b/tests/tools/askBrowserstackE2E.test.ts index 7ab0ff4..5a60ad5 100644 --- a/tests/tools/askBrowserstackE2E.test.ts +++ b/tests/tools/askBrowserstackE2E.test.ts @@ -480,6 +480,53 @@ describe("askBrowserstackAI, end to end through the server factory", () => { expect(elicit).not.toHaveBeenCalled(); expect(payload.approvals).toEqual([]); expect(payload.applied_before_stop).toBe(false); + // ...and the relay verdict must agree with `error` rather than contradict it. + expect(payload.permission_relay).toEqual({ + used: false, + reason: "not_reached", + detail: expect.stringContaining("NOTHING WAS ASKED AND NOTHING WAS REFUSED"), + }); + }); + + it("does not claim the channel was used when Atlas is unreachable", async () => { + const server = await buildServer(); + const elicit = fakeClient(server.getInstance(), { elicitation: {} }, []); + const stub = atlas({ throws: true }); + + const { payload } = await call(server.getTools()); + + // We offered the channel and it was never exercised — zero prompts appeared. + expect(stub.calls[0].body.permission_relay).toBeDefined(); + expect(elicit).not.toHaveBeenCalled(); + expect(payload.status).toBe("error"); + expect(payload.error).toMatch(/could not be reached/); + expect(payload.permission_relay.used).toBe(false); + expect(payload.permission_relay.reason).toBe("not_reached"); + expect(payload.approvals).toEqual([]); + }); + + it.each([ + [400, "task is required"], + [503, "delegation is not enabled"], + ])("reads a %i pre-run refusal as nothing-asked, and says why", async (status, detail) => { + const server = await buildServer(); + const elicit = fakeClient(server.getInstance(), { elicitation: {} }, []); + // Exactly what Atlas answers with before the delegation layer: a bare detail. + vi.stubGlobal("fetch", async () => ({ + status, + headers: { get: () => "application/json" }, + json: async () => ({ detail }), + })); + + const { result, payload } = await call(server.getTools()); + + expect(payload.status).toBe("error"); + expect(result.isError).toBe(true); + expect(payload.error).toContain(`HTTP ${status}`); + expect(payload.error).toContain(detail); + expect(payload.permission_relay.reason).toBe("not_reached"); + expect(elicit).not.toHaveBeenCalled(); + expect(payload.approvals).toEqual([]); }); it("keeps a 401 and a permission denial from reading alike", async () => { From 8584c13ecae38efdc864ba9d3a73460982e914db Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Mon, 24 Aug 2026 17:08:23 +0530 Subject: [PATCH 10/31] Read the approval trail from Atlas instead of guessing at it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Only Atlas can know whether an approved step's request actually landed: the gate returns before anything is sent, which was the whole of D2. So this side stops deriving applied_before_stop from "any allow preceded a deny" — a rule that counted an approval whose egress then failed as applied, and so lied in the exact direction the field exists to prevent. The derivation is deleted rather than left unused, because code that still computes a fact we no longer trust is an invitation to rewire it. A missing applied_before_stop is now null, not false. Atlas sends the field whenever a gate ran, including false and including an empty trail, so absence means either that no gate ran or that this Atlas predates the field. Reporting either as a measured false would assert something nobody checked, in the direction that makes a caller retry a task that already half-applied. Atlas's approvals trail wins whenever it sent one, and an empty trail it did send counts as one — "the relay ran and nothing was asked" is a fact, not a gap to fill from our own records. Each entry is rebuilt on arrival rather than trusted: a decision that is not exactly "allow" reports as a refusal, so a garbled trail fails closed in the reporting the same way the wire does. Our own trail is kept beside it rather than folded in, because where the two disagree the disagreement is the signal. A callback answered with no prompt appearing is a denial to Atlas and nothing at all here, and that pair is what a probe of the loopback port looks like; merging the trails would destroy the only evidence it happened. Every entry now carries a sentence, because "approved, then the request failed" is a genuinely different thing to tell a person than "somebody said no", and an approval nobody measured is neither — it is not rendered as a failure. --- src/tools/ask-browserstack/relay.ts | 113 ++++++++++++++--- src/tools/ask-browserstack/types.ts | 55 +++++++- tests/tools/askBrowserstack.test.ts | 167 +++++++++++++++++++++++-- tests/tools/askBrowserstackE2E.test.ts | 108 ++++++++++++++-- 4 files changed, 402 insertions(+), 41 deletions(-) diff --git a/src/tools/ask-browserstack/relay.ts b/src/tools/ask-browserstack/relay.ts index e192819..2d929e7 100644 --- a/src/tools/ask-browserstack/relay.ts +++ b/src/tools/ask-browserstack/relay.ts @@ -184,19 +184,88 @@ export function decide(result: ElicitResult): { } /** - * CONTRACT §5 — "true if ANY allow preceded a deny". + * Read Atlas's `applied_before_stop`. NEVER DERIVE IT. * - * This is the field that separates "nothing happened" from "some steps applied, then - * stopped". A caller that cannot tell those apart will retry a half-applied task, so the - * literal rule is implemented literally: a run where everything was allowed did not stop, - * and is therefore false. + * This side used to compute it as CONTRACT §5's literal "any allow preceded a deny", which + * could only ever be a guess: an approval whose request then failed counted as applied, so + * the field lied in the exact direction it exists to prevent (D2). Atlas now computes it + * from `applied`, which only Atlas can know, and sends it whenever a gate ran — including + * `false`, including with an empty trail. + * + * So a MISSING field is never "false". It is "nobody measured this": either no gate ran, or + * this Atlas predates the field. `null` says that out loud instead of asserting a fact. + */ +export function readAppliedBeforeStop( + payload: Record, +): boolean | null { + const reported = payload.applied_before_stop; + return typeof reported === "boolean" ? reported : null; +} + +/** + * Atlas's approval trail, when it sent one. + * + * Returns `null` — not `[]` — when the key is absent, because an empty trail Atlas DID send + * ("the relay ran and nothing was asked") is a different fact from no trail at all, and only + * the second is a reason to fall back to ours. + * + * Every entry is rebuilt rather than trusted: a `decision` that is not exactly `"allow"` + * becomes `"deny"`, so a garbled trail fails closed in the reporting the same way the wire + * does, and `applied` is carried only when it is genuinely a boolean. */ -export function appliedBeforeStop(approvals: ApprovalRecord[]): boolean { - const firstDeny = approvals.findIndex((entry) => entry.decision === "deny"); - if (firstDeny === -1) return false; - return approvals - .slice(0, firstDeny) - .some((entry) => entry.decision === "allow"); +export function parseAtlasApprovals( + payload: Record, +): ApprovalRecord[] | null { + const raw = payload.approvals; + if (!Array.isArray(raw)) return null; + const trail: ApprovalRecord[] = []; + for (const item of raw) { + if (typeof item !== "object" || item === null || Array.isArray(item)) continue; + const entry = item as Record; + trail.push({ + description: typeof entry.description === "string" ? entry.description : "", + decision: entry.decision === "allow" ? "allow" : "deny", + reason: typeof entry.reason === "string" ? entry.reason : "", + ...(typeof entry.applied === "boolean" ? { applied: entry.applied } : {}), + }); + } + return trail; +} + +/** + * One phrase per entry, because "approved, then it failed" and "refused" must not read + * alike — conflating them is the whole reason D2 mattered. + * + * An `allow` with no `applied` key is NOT rendered as a failure: nobody measured it, and + * saying otherwise would invent the very fact this is meant to report. + */ +export function approvalOutcome(entry: ApprovalRecord): string { + if (entry.decision === "allow") { + if (entry.applied === true) return "approved, and the change went through"; + if (entry.applied === false) { + return ( + "APPROVED, BUT THE CHANGE DID NOT GO THROUGH — nobody refused it; the request " + + "failed after it was approved" + ); + } + return "approved; whether the change went through was not reported"; + } + switch (entry.reason) { + case "declined": + return "refused: a human said no"; + case "cancelled": + return "refused: nobody was there to be asked"; + case "timeout": + return "refused: nobody answered in time"; + case "error": + return "refused: the approval channel broke before any answer arrived"; + default: + return "refused"; + } +} + +function withOutcomes(trail: ApprovalRecord[]): ApprovalRecord[] { + return trail.map((entry) => ({ ...entry, outcome: approvalOutcome(entry) })); } function asRecord(value: unknown): Record { @@ -284,7 +353,13 @@ export function buildResult( const needsApproval = Array.isArray(payload.needs_approval) ? (payload.needs_approval as unknown[]) : []; - const status = deriveStatus(response, approvals, needsApproval); + // ATLAS'S TRAIL WINS WHEN IT SENT ONE. It is the only side that can fill in `applied`, and + // it records what happened to the STEP: a callback answered without a prompt appearing is + // a denial there and nothing at all here. Ours is kept separately rather than discarded, + // because that difference is exactly how a probe of the loopback port shows up. + const atlasTrail = parseAtlasApprovals(payload); + const trail = atlasTrail ?? approvals; + const status = deriveStatus(response, trail, needsApproval); const reachedAgent = !neverReachedAgent(response); return { @@ -292,9 +367,11 @@ export function buildResult( status, // The product's answer, as the product wrote it. Nothing here summarises or re-reads it. answer: payload.answer ?? null, - approvals, + approvals: withOutcomes(trail), + approvals_source: atlasTrail ? "atlas" : "mcp", + elicitations: withOutcomes(approvals), needs_approval: needsApproval, - applied_before_stop: appliedBeforeStop(approvals), + applied_before_stop: readAppliedBeforeStop(payload), permission_relay: relayVerdict(payload, relayUsed, reachedAgent), atlas_response: response.body ?? null, ...atlasError(response, payload), @@ -371,9 +448,13 @@ export function errorResult(message: string, approvals: ApprovalRecord[]): AskRe ok: false, status: "error", answer: null, - approvals, + // Atlas never answered, so there is no authoritative trail to prefer. Ours is all there + // is, and `applied_before_stop` is null because nobody measured anything. + approvals: withOutcomes(approvals), + approvals_source: "mcp", + elicitations: withOutcomes(approvals), needs_approval: [], - applied_before_stop: appliedBeforeStop(approvals), + applied_before_stop: null, // The request never left this process, so it certainly never reached the agent. permission_relay: { used: false, diff --git a/src/tools/ask-browserstack/types.ts b/src/tools/ask-browserstack/types.ts index 7d49e03..0602632 100644 --- a/src/tools/ask-browserstack/types.ts +++ b/src/tools/ask-browserstack/types.ts @@ -88,11 +88,34 @@ export interface AgentRequest { permission_relay?: PermissionRelay; } -/** CONTRACT §5 — one entry per ask we relayed, in the order they arrived. */ +/** CONTRACT §5 — one entry in an approval trail, in the order the asks arrived. */ export interface ApprovalRecord { description: string; decision: Decision; reason: string; + /** + * Did this approved step's request actually land? (Atlas task 3 §1.) + * + * `true` only when the entry was `allow` AND its request then returned 2xx. Everything + * else — a dead port, a 4xx, a 5xx, an async-dispatch refusal — resolves to `false`: + * unknown fails toward not-applied, never the other way. + * + * ONLY ATLAS CAN KNOW THIS. The gate returns before any request is sent, which was the + * whole of D2; the fact is written by the tool layer into the same record object the gate + * keeps by reference, so it correlates by object identity rather than by position. + * + * ABSENT means not reported (an Atlas that predates this, or our own elicitation trail, + * which has no way to know). Never render an absent value as a measured `false`. + */ + applied?: boolean; + /** + * A human-readable phrase for this entry. + * + * Exists because `allow` + `applied: false` — approved, then the request failed — is a + * genuinely different thing to tell a person than a refusal, and the two must not read + * alike. Derived, never sent by anyone. + */ + outcome?: string; } export const ASK_STATUSES = ["ok", "blocked", "error", "rate_limited"] as const; @@ -104,12 +127,36 @@ export interface AskResult { status: AskStatus; answer: unknown; /** - * The approval trail. Without it a caller cannot tell "nothing happened" from "some - * steps applied, then stopped", and will retry a half-applied task. + * THE AUTHORITATIVE approval trail: Atlas's whenever it supplied one, ours otherwise. + * + * Atlas's wins because it is the only side that can populate `applied`, and because it + * records what happened to the STEP — a callback answered without a prompt appearing (a + * 401'd probe, a shape rejection) is a denial there and nothing at all here. */ approvals: ApprovalRecord[]; + /** Which side produced `approvals`, so a reader never has to infer it. */ + approvals_source: "atlas" | "mcp"; + /** + * OUR trail: one entry per prompt this server actually put in front of a human. + * + * Kept beside `approvals` rather than folded into it, because where the two disagree the + * disagreement is the signal. An entry Atlas records as a denial with nothing here means a + * callback was answered without any prompt appearing — which is what an attacker probing + * the loopback port looks like. + */ + elicitations: ApprovalRecord[]; needs_approval: unknown[]; - applied_before_stop: boolean; + /** + * Atlas's own verdict: this run stopped on a refusal AND something had already changed. + * + * READ, NEVER DERIVED. Atlas computes it because only Atlas knows `applied`, and it sends + * the field whenever a relay gate ran — including `false`, including with an empty trail. + * + * `null` means NOT REPORTED, which is not the same as `false`: either no gate ran (so + * Atlas has nothing to say about applications) or this Atlas predates the field. Treating + * it as `false` would assert something nobody measured. + */ + applied_before_stop: boolean | null; /** * Why a write may have been refused. * diff --git a/tests/tools/askBrowserstack.test.ts b/tests/tools/askBrowserstack.test.ts index 9113fef..63cf521 100644 --- a/tests/tools/askBrowserstack.test.ts +++ b/tests/tools/askBrowserstack.test.ts @@ -11,7 +11,7 @@ import { } from "../../src/tools/ask-browserstack/config.js"; import { agentHeaders } from "../../src/tools/ask-browserstack/egress.js"; import { - appliedBeforeStop, + approvalOutcome, buildResult, decide, errorResult, @@ -89,24 +89,164 @@ describe("decide — CONTRACT §7, and nothing but", () => { }); }); -describe("applied_before_stop — the field that stops a half-applied retry", () => { - const allow: ApprovalRecord = { description: "a", decision: "allow", reason: "" }; - const deny: ApprovalRecord = { description: "b", decision: "deny", reason: "declined" }; +describe("applied_before_stop — read from Atlas, never derived (D2/D4)", () => { + function build(body: Record) { + return buildResult({ status: 200, body: { status: "ok", answer: "", ...body } }, [], true); + } - it("is false when nothing was asked", () => { - expect(appliedBeforeStop([])).toBe(false); + it("reports exactly what Atlas said, both ways", () => { + expect(build({ applied_before_stop: true }).applied_before_stop).toBe(true); + expect(build({ applied_before_stop: false }).applied_before_stop).toBe(false); }); - it("is false when the FIRST thing asked was refused: nothing happened", () => { - expect(appliedBeforeStop([deny, allow])).toBe(false); + it("reports null — NOT false — when Atlas said nothing", () => { + // Absence means no gate ran, or an Atlas that predates the field. Rendering it as + // `false` would assert something nobody measured, in the direction that makes a caller + // retry a task that already half-applied. + expect(build({}).applied_before_stop).toBeNull(); }); - it("is true when an allow preceded a deny: some steps applied, then it stopped", () => { - expect(appliedBeforeStop([allow, deny])).toBe(true); + it("ignores a non-boolean rather than coercing it", () => { + expect(build({ applied_before_stop: "true" }).applied_before_stop).toBeNull(); + expect(build({ applied_before_stop: 1 }).applied_before_stop).toBeNull(); + expect(build({ applied_before_stop: null }).applied_before_stop).toBeNull(); }); - it("is false when everything was allowed, because nothing stopped", () => { - expect(appliedBeforeStop([allow, allow])).toBe(false); + it("does not re-derive it from the trail it can see", () => { + // The old rule ("any allow preceded a deny") would have said true here. Atlas says + // false, because the approved step's request never landed — which only Atlas knows. + const result = build({ + approvals: [ + { description: "a", decision: "allow", reason: "", applied: false }, + { description: "b", decision: "deny", reason: "declined", applied: false }, + ], + applied_before_stop: false, + }); + expect(result.applied_before_stop).toBe(false); + }); +}); + +describe("the authoritative approval trail (D4)", () => { + const MINE: ApprovalRecord[] = [ + { description: "Creating the Alpha folder", decision: "allow", reason: "" }, + ]; + + function build(body: Record, mine = MINE) { + return buildResult({ status: 200, body: { status: "ok", answer: "", ...body } }, mine, true); + } + + it("prefers Atlas's trail over ours, and says which it used", () => { + const result = build({ + approvals: [ + { description: "Creating the Alpha folder", decision: "allow", reason: "", applied: true }, + ], + }); + expect(result.approvals_source).toBe("atlas"); + expect(result.approvals[0].applied).toBe(true); + }); + + it("keeps our own trail beside it, because the disagreement IS the signal", () => { + // A callback answered with no prompt appearing — an attacker probing the loopback port + // — is a denial to Atlas and nothing at all to us. Folding the two together would + // destroy the only evidence that it happened. + const result = build({ + approvals: [{ description: "Creating the Alpha folder", decision: "deny", reason: "error", applied: false }], + }, []); + expect(result.approvals_source).toBe("atlas"); + expect(result.approvals[0].decision).toBe("deny"); + expect(result.elicitations).toEqual([]); + }); + + it("falls back to ours only when Atlas sent no trail at all", () => { + const result = build({}); + expect(result.approvals_source).toBe("mcp"); + expect(result.approvals[0].description).toBe("Creating the Alpha folder"); + }); + + it("treats an EMPTY trail from Atlas as a trail, not as absence", () => { + // "The relay ran and nothing was asked" is a fact Atlas sent; ours is not a better + // answer to it. + const result = build({ approvals: [] }); + expect(result.approvals_source).toBe("atlas"); + expect(result.approvals).toEqual([]); + }); + + it("rebuilds each entry rather than trusting it, failing closed on a garbled decision", () => { + const result = build({ + approvals: [ + { description: "x", decision: "ALLOW", reason: "" }, + { description: "y", decision: "allow", reason: "" }, + "not an entry", + null, + ], + }); + // Anything that is not exactly "allow" reports as a refusal. + expect(result.approvals.map((e) => e.decision)).toEqual(["deny", "allow"]); + }); + + it("carries `applied` only when it is genuinely a boolean", () => { + const result = build({ + approvals: [ + { description: "a", decision: "allow", reason: "", applied: "yes" }, + { description: "b", decision: "allow", reason: "" }, + ], + }); + expect("applied" in result.approvals[0]).toBe(false); + expect("applied" in result.approvals[1]).toBe(false); + }); +}); + +describe("approvalOutcome — 'approved then failed' must not read like a refusal", () => { + const base = { description: "Creating the Alpha folder", reason: "" }; + + it("distinguishes an applied allow from one whose request failed", () => { + const applied = approvalOutcome({ ...base, decision: "allow", applied: true }); + const failed = approvalOutcome({ ...base, decision: "allow", applied: false }); + expect(applied).toBe("approved, and the change went through"); + expect(failed).toMatch(/APPROVED, BUT THE CHANGE DID NOT GO THROUGH/); + expect(failed).toMatch(/nobody refused it/); + expect(applied).not.toBe(failed); + }); + + it("does not render an unmeasured allow as a failure", () => { + // An older Atlas, or our own trail, simply does not know. + const unknown = approvalOutcome({ ...base, decision: "allow" }); + expect(unknown).toBe("approved; whether the change went through was not reported"); + expect(unknown).not.toMatch(/DID NOT GO THROUGH/); + }); + + it("keeps a failed write clearly apart from every kind of refusal", () => { + const failed = approvalOutcome({ ...base, decision: "allow", applied: false }); + for (const reason of ["declined", "cancelled", "timeout", "error", "anything-else"]) { + const refusal = approvalOutcome({ ...base, decision: "deny", reason }); + expect(refusal).toMatch(/^refused/); + expect(refusal).not.toBe(failed); + } + }); + + it("says WHICH kind of refusal, since they call for different things", () => { + const of = (reason: string) => approvalOutcome({ ...base, decision: "deny", reason }); + expect(of("declined")).toMatch(/a human said no/); + expect(of("cancelled")).toMatch(/nobody was there/); + expect(of("timeout")).toMatch(/nobody answered in time/); + expect(of("error")).toMatch(/channel broke/); + expect(of("something-new")).toBe("refused"); + }); + + it("is attached to every entry in both trails", () => { + const result = buildResult( + { + status: 200, + body: { + status: "ok", answer: "", + approvals: [{ description: "a", decision: "allow", reason: "", applied: false }], + }, + }, + [{ description: "a", decision: "allow", reason: "" }], + true, + ); + expect(result.approvals[0].outcome).toMatch(/DID NOT GO THROUGH/); + expect(result.elicitations[0].outcome).toMatch(/not reported/); }); }); @@ -532,7 +672,8 @@ describe("pre-run refusals — the request never reached the agent (D3)", () => expect(typeof result.error).toBe("string"); expect(result.error!.length).toBeGreaterThan(0); expect(result.approvals).toEqual([]); - expect(result.applied_before_stop).toBe(false); + // Nobody measured anything, so the field asserts nothing. + expect(result.applied_before_stop).toBeNull(); }); it("names the HTTP status and quotes Atlas's detail on a 400 and a 503", () => { diff --git a/tests/tools/askBrowserstackE2E.test.ts b/tests/tools/askBrowserstackE2E.test.ts index 5a60ad5..abb1103 100644 --- a/tests/tools/askBrowserstackE2E.test.ts +++ b/tests/tools/askBrowserstackE2E.test.ts @@ -126,7 +126,15 @@ describe("askBrowserstackAI, end to end through the server factory", () => { ]); const stub = atlas({ asks: [{ perm_id: PERM_A, description: "Create folder \"Regression\"." }], - payload: () => ({ status: "ok", answer: "Created folder 12.", needs_approval: [] }), + payload: () => ({ + ok: true, status: "ok", answer: "Created folder 12.", steps: [], + // Atlas's authoritative trail, with the `applied` bit only it can fill in. + approvals: [ + { description: "Create folder \"Regression\".", decision: "allow", reason: "", applied: true }, + ], + applied_before_stop: false, + permission_relay: { used: true, reason: "" }, + }), }); const { payload } = await call(server.getTools()); @@ -165,8 +173,16 @@ describe("askBrowserstackAI, end to end through the server factory", () => { // 4. the result expect(payload.ok).toBe(true); expect(payload.answer).toBe("Created folder 12."); - expect(payload.approvals) - .toEqual([{ description: "Create folder \"Regression\".", decision: "allow", reason: "" }]); + // Atlas's trail wins, carrying the applied bit; ours is kept beside it. + expect(payload.approvals_source).toBe("atlas"); + expect(payload.approvals).toEqual([{ + description: "Create folder \"Regression\".", decision: "allow", reason: "", + applied: true, outcome: "approved, and the change went through", + }]); + expect(payload.elicitations).toEqual([{ + description: "Create folder \"Regression\".", decision: "allow", reason: "", + outcome: "approved; whether the change went through was not reported", + }]); expect(payload.applied_before_stop).toBe(false); expect(payload.permission_relay.used).toBe(true); }); @@ -178,7 +194,13 @@ describe("askBrowserstackAI, end to end through the server factory", () => { ]); const stub = atlas({ asks: [{ perm_id: PERM_A, description: "Delete the sprint." }], - payload: () => ({ status: "blocked", answer: "", needs_approval: ["Delete the sprint."] }), + payload: () => ({ + status: "blocked", answer: "", needs_approval: ["Delete the sprint."], + approvals: [ + { description: "Delete the sprint.", decision: "deny", reason: "declined", applied: false }, + ], + applied_before_stop: false, + }), }); const { payload } = await call(server.getTools()); @@ -188,6 +210,8 @@ describe("askBrowserstackAI, end to end through the server factory", () => { expect(elicit).toHaveBeenCalledTimes(1); expect(payload.status).toBe("blocked"); expect(payload.approvals[0].reason).toBe("declined"); + expect(payload.approvals[0].outcome).toBe("refused: a human said no"); + // Nothing had applied, so a retry of the whole task is safe. expect(payload.applied_before_stop).toBe(false); }); @@ -240,6 +264,7 @@ describe("askBrowserstackAI, end to end through the server factory", () => { expect(stub.decisions[0].status).toBe(500); expect(payload.approvals[0]).toEqual({ description: "Archive the plan.", decision: "deny", reason: "error", + outcome: "refused: the approval channel broke before any answer arrived", }); }); @@ -272,13 +297,24 @@ describe("askBrowserstackAI, end to end through the server factory", () => { payload: () => ({ status: "blocked", answer: "Created the folder; the move was refused.", needs_approval: ["Move 40 test cases into it."], + approvals: [ + { description: "Create folder \"Regression\".", decision: "allow", reason: "", applied: true }, + { description: "Move 40 test cases into it.", decision: "deny", reason: "declined", applied: false }, + ], + // Atlas's own verdict: it stopped on a refusal AND something had already changed. + applied_before_stop: true, }), }); const { payload } = await call(server.getTools()); - // The whole point of the field: a caller must not retry this task from scratch. + // The whole point of the field: a caller must not retry this task from scratch. It is + // READ from Atlas, which is the only side that knows the folder creation landed. expect(payload.applied_before_stop).toBe(true); + expect(payload.approvals_source).toBe("atlas"); expect(payload.approvals.map((a: any) => a.decision)).toEqual(["allow", "deny"]); + expect(payload.approvals.map((a: any) => a.applied)).toEqual([true, false]); + expect(payload.approvals[0].outcome).toBe("approved, and the change went through"); + expect(payload.approvals[1].outcome).toBe("refused: a human said no"); expect(payload.needs_approval).toEqual(["Move 40 test cases into it."]); }); @@ -305,7 +341,8 @@ describe("askBrowserstackAI, end to end through the server factory", () => { detail: expect.stringContaining("NOBODY DECLINED THIS"), }); expect(payload.approvals).toEqual([]); - expect(payload.applied_before_stop).toBe(false); + // No gate ran, so Atlas omits the field and we must not invent a measured `false`. + expect(payload.applied_before_stop).toBeNull(); // A refusal is not a tool failure, and rendering it as one invites the retry loop // these distinct reasons exist to prevent. expect(result.isError).toBeUndefined(); @@ -350,6 +387,60 @@ describe("askBrowserstackAI, end to end through the server factory", () => { expect(result.isError).toBe(true); }); + it("keeps the two trails apart when a probe is answered with no prompt (D4)", async () => { + const server = await buildServer(); + const elicit = fakeClient(server.getInstance(), { elicitation: {} }, []); + // A callback arriving with the wrong bearer: 401, zero prompts. Atlas sees the STEP + // refused and records a denial; we saw nobody, and recorded nothing. + const stub = atlas({ + asks: [{ perm_id: PERM_A, description: "Create folder." }], + token: () => "a-stray-local-process", + payload: () => ({ + status: "blocked", answer: "", needs_approval: ["Create folder."], + approvals: [ + { description: "Create folder.", decision: "deny", reason: "error", applied: false }, + ], + applied_before_stop: false, + permission_relay: { used: true, reason: "" }, + }), + }); + + const { payload } = await call(server.getTools()); + + expect(stub.decisions[0].status).toBe(401); + expect(elicit).not.toHaveBeenCalled(); + // Atlas's is authoritative... + expect(payload.approvals_source).toBe("atlas"); + expect(payload.approvals[0].decision).toBe("deny"); + // ...and ours is empty, which is the ONLY record that no prompt ever appeared. That + // difference is what a probe of the loopback port looks like, so it must survive. + expect(payload.elicitations).toEqual([]); + expect(payload.applied_before_stop).toBe(false); + }); + + it("degrades cleanly when an older Atlas sends a trail with no applied bit", async () => { + const server = await buildServer(); + fakeClient(server.getInstance(), { elicitation: {} }, [ + { action: "accept", content: { confirm: true } }, + ]); + atlas({ + asks: [{ perm_id: PERM_A, description: "Create folder." }], + payload: () => ({ + status: "ok", answer: "done", + approvals: [{ description: "Create folder.", decision: "allow", reason: "" }], + // and no applied_before_stop either + }), + }); + + const { payload } = await call(server.getTools()); + expect("applied" in payload.approvals[0]).toBe(false); + // Not rendered as a failure: nobody measured it. + expect(payload.approvals[0].outcome).toBe( + "approved; whether the change went through was not reported", + ); + expect(payload.applied_before_stop).toBeNull(); + }); + it("tears the listener down once the call ends, even when the call failed", async () => { const server = await buildServer(); fakeClient(server.getInstance(), { elicitation: {} }, []); @@ -386,7 +477,8 @@ describe("askBrowserstackAI, end to end through the server factory", () => { expect(payload.status).toBe("blocked"); expect(payload.approvals).toEqual([]); - expect(payload.applied_before_stop).toBe(false); + expect(payload.approvals_source).toBe("mcp"); + expect(payload.applied_before_stop).toBeNull(); expect(payload.needs_approval).toEqual(["Create folder \"Regression\"."]); expect(payload.permission_relay).toEqual({ used: false, @@ -479,7 +571,7 @@ describe("askBrowserstackAI, end to end through the server factory", () => { // Nothing was ever asked, so nothing can look like a refusal. expect(elicit).not.toHaveBeenCalled(); expect(payload.approvals).toEqual([]); - expect(payload.applied_before_stop).toBe(false); + expect(payload.applied_before_stop).toBeNull(); // ...and the relay verdict must agree with `error` rather than contradict it. expect(payload.permission_relay).toEqual({ used: false, From aebf512360d31a86c7510ff0f6e5a9faf79c1df5 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Mon, 24 Aug 2026 17:49:51 +0530 Subject: [PATCH 11/31] Let the body decide whether a run happened, not the HTTP status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Atlas answers HTTP 502 with a complete result body when a delegation ran and a step then failed, and 429 carries a full body too. The not-reached rule read "any non-2xx" and ran ahead of Atlas's own verdict, so an approved write whose egress failed came back saying nothing had been asked and nothing refused — while approvals in the same payload showed the prompt shown, approved, and not applied. That is the lie the rule was added to prevent, now told on the one run where it costs the most: the reader is talked out of checking for a partial application at exactly the moment one is possible. The status code describes the outcome; the body describes whether there was a run. Only the second question decides this, so a body carrying ok, status, answer or approvals means the delegation ran, whatever code carried it, and not-reached is left to a transport failure or a body that is not a result at all. Status derivation and the error string follow the same rule: a run's own status outranks its transport code, and "refused before the agent started" is no longer said over a run that plainly started. A 2xx carrying no result used to report ok: true with an error attached. Real Atlas never emits that, but a shape that contradicts itself is not a shape to leave lying around, so it now reads as the error it is. The tests assert the whole permission_relay object beside approvals rather than picking at single fields, because this ordering has now bitten twice and the failure mode both times was two fields in one payload disagreeing. --- src/tools/ask-browserstack/relay.ts | 96 ++++++++++----- tests/tools/askBrowserstack.test.ts | 157 ++++++++++++++++++++++++- tests/tools/askBrowserstackE2E.test.ts | 56 +++++++++ 3 files changed, 275 insertions(+), 34 deletions(-) diff --git a/src/tools/ask-browserstack/relay.ts b/src/tools/ask-browserstack/relay.ts index 2d929e7..df9674b 100644 --- a/src/tools/ask-browserstack/relay.ts +++ b/src/tools/ask-browserstack/relay.ts @@ -95,15 +95,31 @@ export function relayDetail(used: boolean, reason: string): string { * Which is the same confusion the `disabled` sentence exists to prevent, one layer earlier: * a caller who cannot tell "nobody was asked" from "somebody said no" retries forever. */ +export function looksLikeDelegationResult(body: unknown): boolean { + const payload = asRecord(body); + return ( + "ok" in payload || + "status" in payload || + "answer" in payload || + "approvals" in payload + ); +} + export function neverReachedAgent(response: AgentResponse): boolean { - // No response at all, or one that is not a success. + // No response at all: nothing could have run. if (response.status === 0) return true; - if (response.status < 200 || response.status >= 300) return true; - // A success carrying a bare `detail` is not a delegation result either. - const payload = asRecord(response.body); - const looksLikeAResult = - "ok" in payload || "status" in payload || "answer" in payload; - return "detail" in payload && !looksLikeAResult; + // THE BODY DECIDES, NOT THE STATUS. + // + // This rung first read "any non-2xx", and that was wrong in the one direction that + // matters. Atlas answers HTTP 502 with a COMPLETE result body when a delegation ran and a + // step then failed (`delegation/http.py:317-320`), and 429 carries a full body too. So an + // approved write whose egress failed came back saying nobody had been asked, while + // `approvals` in the same payload showed the prompt shown and approved — the exact lie + // this predicate was added to prevent, now told on the one run where it costs the most. + // + // The HTTP code describes the OUTCOME; the body describes whether there was a RUN. Only + // the second question is being asked here. + return !looksLikeDelegationResult(response.body); } /** @@ -283,20 +299,26 @@ export function deriveStatus( approvals: ApprovalRecord[], needsApproval: unknown[], ): AskStatus { - if (response.status === 429) return "rate_limited"; - // Status 0 (unreachable) lands here too, which is what it is: a failed call. - if (response.status < 200 || response.status >= 300) return "error"; - - const declared = asRecord(response.body).status; - if ( - typeof declared === "string" && - (ASK_STATUSES as readonly string[]).includes(declared) - ) { - return declared as AskStatus; + if (looksLikeDelegationResult(response.body)) { + // A run happened, so its own status is the answer — on a 502 and a 429 as much as on a + // 200. Reading the HTTP code first would overwrite what the run said about itself. + const declared = asRecord(response.body).status; + if ( + typeof declared === "string" && + (ASK_STATUSES as readonly string[]).includes(declared) + ) { + return declared as AskStatus; + } + if (response.status === 429) return "rate_limited"; + if (response.status < 200 || response.status >= 300) return "error"; + const denied = approvals.some((entry) => entry.decision === "deny"); + return denied || needsApproval.length > 0 ? "blocked" : "ok"; } - const denied = approvals.some((entry) => entry.decision === "deny"); - return denied || needsApproval.length > 0 ? "blocked" : "ok"; + // No run to speak of. A 2xx lands here too when the body is not a result — which is what + // made that case report `ok: true` alongside an `error` (N4). + if (response.status === 429) return "rate_limited"; + return "error"; } /** @@ -411,29 +433,41 @@ function atlasError( payload: Record, ): { error?: string } { if (response.status === 0 && response.error) return { error: response.error }; - if (response.status === 401) return { error: UNAUTHENTICATED_DETAIL }; + // Atlas's own error string wherever it sent one — on a 502 that carries a full result, + // this is the run explaining its own failure, and nothing here should talk over it. const reported = payload.error; if (typeof reported === "string" && reported) return { error: reported }; - const detail = payload.detail; - if (typeof detail === "string" && detail) { - // Bounded: this came off the wire and ends up in front of a person. - return { - error: - `BrowserStack AI refused this request before the agent started ` + - `(HTTP ${response.status}): ${JSON.stringify(detail.slice(0, 200))}.`, - }; + if (response.status === 401) return { error: UNAUTHENTICATED_DETAIL }; + + if (!neverReachedAgent(response)) { + // The delegation ran. `status`, `approvals` and `needs_approval` already say what + // happened; "refused before the agent started" would simply be false. + return {}; } - // A non-2xx with nothing to say for itself still beats a silent one. + + // Bounded: this came off the wire and ends up in front of a person. + const detail = payload.detail; + const quoted = + typeof detail === "string" && detail + ? `: ${JSON.stringify(detail.slice(0, 200))}` + : ""; + if (response.status < 200 || response.status >= 300) { return { error: `BrowserStack AI refused this request before the agent started ` + - `(HTTP ${response.status}).`, + `(HTTP ${response.status})${quoted}.`, }; } - return {}; + // A 2xx carrying something that is not a delegation result at all. Real Atlas does not + // emit this; saying so plainly beats reporting a success with an error attached (N4). + return { + error: + `BrowserStack AI answered HTTP ${response.status} with no delegation ` + + `result${quoted}.`, + }; } /** diff --git a/tests/tools/askBrowserstack.test.ts b/tests/tools/askBrowserstack.test.ts index 63cf521..52bae49 100644 --- a/tests/tools/askBrowserstack.test.ts +++ b/tests/tools/askBrowserstack.test.ts @@ -17,6 +17,7 @@ import { errorResult, deriveStatus, elicitationMessage, + looksLikeDelegationResult, neverReachedAgent, } from "../../src/tools/ask-browserstack/relay.js"; import { ApprovalRecord } from "../../src/tools/ask-browserstack/types.js"; @@ -256,10 +257,13 @@ describe("result assembly", () => { }); it("calls a run blocked when something was denied or left needing approval", () => { + // A real result that simply declared no usable status — `ok` and `answer` are what mark + // it as a delegation result at all. + const ran = { ok: true, answer: "" }; const denied: ApprovalRecord[] = [{ description: "d", decision: "deny", reason: "cancelled" }]; - expect(deriveStatus({ status: 200, body: {} }, denied, [])).toBe("blocked"); - expect(deriveStatus({ status: 200, body: {} }, [], ["a write"])).toBe("blocked"); - expect(deriveStatus({ status: 200, body: {} }, [], [])).toBe("ok"); + expect(deriveStatus({ status: 200, body: ran }, denied, [])).toBe("blocked"); + expect(deriveStatus({ status: 200, body: ran }, [], ["a write"])).toBe("blocked"); + expect(deriveStatus({ status: 200, body: ran }, [], [])).toBe("ok"); }); it("maps 429 to rate_limited and every other non-2xx, including 0, to error", () => { @@ -746,3 +750,150 @@ describe("pre-run refusals — the request never reached the agent (D3)", () => expect(notReached.detail).toMatch(/run it again/); }); }); + +describe("a result body outranks the HTTP status (N1)", () => { + // Atlas answers 502 with a COMPLETE result when a delegation ran and a step failed, and + // 429 carries a full body too. The status describes the OUTCOME; the body describes + // whether there was a RUN, and only the second question decides `not_reached`. + const RAN = { + ok: false, + status: "error", + answer: "The folder was not created.", + approvals: [ + { + description: 'Creating the "Regression" folder.', + decision: "allow", + reason: "", + applied: false, + }, + ], + applied_before_stop: false, + permission_relay: { used: true, reason: "" }, + }; + + it.each([[502], [429], [500], [503]])( + "treats HTTP %i carrying a real result as a run that happened", + (status) => { + expect(neverReachedAgent({ status, body: RAN })).toBe(false); + }, + ); + + it("reports the approval that WAS shown, on the 502 that regressed", () => { + const result = buildResult({ status: 502, body: RAN }, [], true); + + // The whole object, so `permission_relay` and `approvals` can never disagree again + // without this failing. + expect(result.permission_relay).toEqual({ + used: true, + reason: "", + detail: expect.stringContaining("asked before each change"), + }); + expect(result.permission_relay.detail).not.toMatch(/NOTHING WAS ASKED/); + expect(result.approvals).toEqual([ + { + description: 'Creating the "Regression" folder.', + decision: "allow", + reason: "", + applied: false, + outcome: expect.stringContaining("APPROVED, BUT THE CHANGE DID NOT GO THROUGH"), + }, + ]); + expect(result.approvals_source).toBe("atlas"); + // Atlas's status and its own verdict, not one derived from the 502. + expect(result.status).toBe("error"); + expect(result.applied_before_stop).toBe(false); + // "refused before the agent started" would be false — the agent ran. + expect(result.error).toBeUndefined(); + }); + + it("keeps Atlas's own status on a 429 with a full body", () => { + const result = buildResult( + { + status: 429, + body: { ok: false, status: "rate_limited", answer: "", approvals: [], applied_before_stop: false }, + }, + [], true, + ); + expect(result.status).toBe("rate_limited"); + expect(result.permission_relay.used).toBe(true); + expect(result.permission_relay.reason).toBe(""); + expect(result.applied_before_stop).toBe(false); + }); + + it("still honours Atlas's `disabled` verdict on a result-carrying non-2xx", () => { + const result = buildResult( + { + status: 502, + body: { ok: false, status: "error", answer: "", + permission_relay: { used: false, reason: "disabled" } }, + }, + [], true, + ); + expect(result.permission_relay.reason).toBe("disabled"); + expect(result.permission_relay.detail).toMatch(/NOBODY DECLINED THIS/); + }); + + it("lets Atlas's own error string speak on a 502 rather than talking over it", () => { + const result = buildResult( + { status: 502, body: { ok: false, status: "error", answer: "", error: "the folder API returned 500" } }, + [], true, + ); + expect(result.error).toBe("the folder API returned 500"); + }); + + it("still calls a bare {detail} refusal not_reached, whatever else changed", () => { + for (const status of [400, 401, 403, 503]) { + const result = buildResult({ status, body: { detail: "nope" } }, [], true); + expect(neverReachedAgent({ status, body: { detail: "nope" } })).toBe(true); + expect(result.permission_relay.used).toBe(false); + expect(result.permission_relay.reason).toBe("not_reached"); + expect(result.applied_before_stop).toBeNull(); + expect(result.approvals).toEqual([]); + } + }); + + it("still calls a transport failure not_reached", () => { + const result = buildResult( + { status: 0, body: null, error: "BrowserStack AI could not be reached" }, [], true, + ); + expect(result.permission_relay.reason).toBe("not_reached"); + expect(result.applied_before_stop).toBeNull(); + expect(result.error).toBe("BrowserStack AI could not be reached"); + }); + + it("recognises a result by any of the four keys, and nothing by none of them", () => { + for (const key of ["ok", "status", "answer", "approvals"]) { + expect(looksLikeDelegationResult({ [key]: null })).toBe(true); + } + expect(looksLikeDelegationResult({ detail: "x" })).toBe(false); + expect(looksLikeDelegationResult({})).toBe(false); + expect(looksLikeDelegationResult(null)).toBe(false); + expect(looksLikeDelegationResult("a string")).toBe(false); + // A result that also carries a detail field is still a result. + expect(looksLikeDelegationResult({ ok: true, detail: "fyi" })).toBe(true); + }); +}); + +describe("a 2xx carrying no delegation result is internally consistent (N4)", () => { + it("does not report success alongside an error", () => { + const result = buildResult({ status: 200, body: { detail: "nope" } }, [], true); + // It used to say ok: true AND carry an error — a shape real Atlas never emits, but one + // that contradicted itself. + expect(result.ok).toBe(false); + expect(result.status).toBe("error"); + expect(result.error).toBe( + 'BrowserStack AI answered HTTP 200 with no delegation result: "nope".', + ); + expect(result.permission_relay.reason).toBe("not_reached"); + expect(result.applied_before_stop).toBeNull(); + }); + + it("says the same about a 2xx with an empty or unparseable body", () => { + for (const body of [{}, null, "not json"]) { + const result = buildResult({ status: 200, body }, [], true); + expect(result.ok).toBe(false); + expect(result.status).toBe("error"); + expect(result.error).toBe("BrowserStack AI answered HTTP 200 with no delegation result."); + } + }); +}); diff --git a/tests/tools/askBrowserstackE2E.test.ts b/tests/tools/askBrowserstackE2E.test.ts index abb1103..e71729a 100644 --- a/tests/tools/askBrowserstackE2E.test.ts +++ b/tests/tools/askBrowserstackE2E.test.ts @@ -441,6 +441,62 @@ describe("askBrowserstackAI, end to end through the server factory", () => { expect(payload.applied_before_stop).toBeNull(); }); + it("does not claim nobody was asked when a 502 carries a real result (N1)", async () => { + const server = await buildServer(); + const elicit = fakeClient(server.getInstance(), { elicitation: {} }, [ + { action: "accept", content: { confirm: true } }, + ]); + // Atlas answers 502 when a delegation RAN and a step then failed. One prompt was + // shown and approved; the write did not land. + const realFetchLocal = realFetch; + vi.stubGlobal("fetch", async (url: string, init: any) => { + const body = JSON.parse(init.body); + await realFetchLocal(body.permission_relay.callback_url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${body.permission_relay.token}`, + }, + body: JSON.stringify({ + perm_id: PERM_A, product: "tm", mode: "ask-always", + description: 'Creating the "Regression" folder.', + }), + }); + return { + status: 502, + headers: { get: () => "application/json" }, + json: async () => ({ + ok: false, status: "error", answer: "The folder was not created.", steps: [], + approvals: [{ + description: 'Creating the "Regression" folder.', + decision: "allow", reason: "", applied: false, + }], + applied_before_stop: false, + permission_relay: { used: true, reason: "" }, + }), + }; + }); + + const { payload } = await call(server.getTools()); + + // A prompt WAS shown and approved — the client recorded it. + expect(elicit).toHaveBeenCalledTimes(1); + expect(payload.elicitations[0].decision).toBe("allow"); + // ...so nothing in the payload may say otherwise. + expect(payload.permission_relay).toEqual({ + used: true, reason: "", + detail: expect.stringContaining("asked before each change"), + }); + expect(payload.permission_relay.detail).not.toMatch(/NOTHING WAS ASKED/); + expect(payload.approvals[0]).toMatchObject({ + decision: "allow", applied: false, + outcome: expect.stringContaining("APPROVED, BUT THE CHANGE DID NOT GO THROUGH"), + }); + expect(payload.approvals_source).toBe("atlas"); + expect(payload.status).toBe("error"); + expect(payload.applied_before_stop).toBe(false); + }); + it("tears the listener down once the call ends, even when the call failed", async () => { const server = await buildServer(); fakeClient(server.getInstance(), { elicitation: {} }, []); From 6026201ee6751b4a417c9fcc08bcc519f7dec33c Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Mon, 24 Aug 2026 18:26:14 +0530 Subject: [PATCH 12/31] Sign in with a central JWT instead of a shared secret MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shared delegation token is gone from Atlas, so the only way in is a BrowserStack central JWT minted from the caller's own username and access key. That is a better door than the one it replaces, not just a different one: validate_delegation_token refuses any token without user claims, so the minted one is user-attested. Atlas sets principal_verified, takes the acting user from signed claims rather than from our request body, and re-uses the same JWT for product egress — so the write a human approves runs as that human rather than as a shared service account. The user_id we send stops being a claim anyone could forge, and is kept only because dropping a field from a frozen wire format is not a change one side gets to make alone. Both halves of the scope are load-bearing and neither can be tidied away. central_ai_s2s is what Atlas matches on, but it is a client_id+secret scope and asking for it alone with a username and access key is refused outright; it becomes obtainable only paired with oauth_user_profile. The constant says so, with the citation, because it reads like redundancy and is not. Tokens are cached rather than minted per call, and the staleness margin is the whole /agent budget plus a minute rather than the usual small skew. Atlas holds this token for the life of the run and re-uses it for egress, so handing out one with sixty seconds left would mean a human approves a write and the request that follows dies on an expired credential — the precise failure the cache exists to avoid. The key is hashed rather than stored, so rotating a credential mints immediately without leaving the secret in a map for the life of the process. The token endpoint's error body can echo the credential straight back, so only its status ever crosses. Nothing here logs the access key or the minted token, and neither appears in a result or an error. Three failures that look alike and are not: the endpoint refusing the credential, the endpoint being unreachable, and Atlas refusing a token we minted successfully. The third is what a misconfigured deployment actually hits, and it now says so — the credentials were fine and required_scope is the likely cause — rather than telling someone their password is wrong. None of the three is a permission denial, and all three still report as never having reached the agent. --- src/tools/ask-browserstack/central-oauth.ts | 235 ++++++++++++++++++++ src/tools/ask-browserstack/config.ts | 28 +-- src/tools/ask-browserstack/register.ts | 46 ++-- src/tools/ask-browserstack/relay.ts | 9 +- tests/tools/askBrowserstack.test.ts | 203 +++++++++++++++-- tests/tools/askBrowserstackE2E.test.ts | 203 +++++++++++++---- 6 files changed, 625 insertions(+), 99 deletions(-) create mode 100644 src/tools/ask-browserstack/central-oauth.ts diff --git a/src/tools/ask-browserstack/central-oauth.ts b/src/tools/ask-browserstack/central-oauth.ts new file mode 100644 index 0000000..bcc5a6e --- /dev/null +++ b/src/tools/ask-browserstack/central-oauth.ts @@ -0,0 +1,235 @@ +/** + * Mint a BrowserStack central-OAuth JWT from the caller's username and access key. + * + * This replaces a shared delegation token, and the upgrade is not cosmetic. + * `validate_delegation_token` refuses any token without `user.user_id`/`user.group_id`, so + * what we mint here is USER-ATTESTED: Atlas sets `principal_verified=True`, takes the acting + * user from signed claims rather than from anything we put in the request body, and reuses + * this same JWT as its `egress_token` — so the product call a human approves runs as that + * human, not as a shared service account. + * + * SECRET HYGIENE IS THE WHOLE POINT OF THIS MODULE, and Atlas's `central_oauth.py` learned + * it the hard way: "The body can echo the credential back on some errors, so it is NOT + * logged or raised — only the status." Neither the access key nor the minted token is ever + * logged, returned, or put in an error message. Only a status code is. + */ + +import { createHash } from "node:crypto"; + +import logger from "../../logger.js"; +import { AGENT_TIMEOUT_MS, AskError } from "./config.js"; +import { Credentials } from "./egress.js"; + +/** + * BOTH PARTS ARE REQUIRED — do not "tidy" this into one scope. + * + * `central_ai_s2s` is what Atlas matches on (`delegation.required_scope`, checked as exact + * membership of the token's `scopes` claim in `web/oauth.py`). But it is a client_id+secret + * scope, and asking for it ALONE with a username+access_key is rejected outright. It becomes + * obtainable only when paired with `oauth_user_profile` — see the note at `web/oauth.py:449`: + * "today any user can obtain `central_ai_s2s` by pairing it with `oauth_user_profile`, + * because the railsApp guard uses `none?` where it means `all?`". + * + * So the pair is load-bearing in both directions: drop `central_ai_s2s` and Atlas refuses the + * token; drop `oauth_user_profile` and the endpoint refuses to issue it. + */ +export const CENTRAL_SCOPE = "oauth_user_profile central_ai_s2s"; + +/** What we ask for. The endpoint clamps to its own maximum, so the response wins. */ +export const REQUESTED_EXPIRES_IN = 3600; + +/** + * Treat a token as stale this long before it actually expires. + * + * NOT the usual small skew. This token is not merely used to open the request — Atlas holds + * it for the life of the run and re-uses it for product egress, so it has to outlive the + * whole call, and our own `/agent` budget is already 330s. Handing out a token with 61 + * seconds left would mean a human approves a write and the egress that follows fails on an + * expired credential, which is the exact mid-flight expiry this cache exists to prevent. + */ +export const REFRESH_SKEW_MS = AGENT_TIMEOUT_MS + 60_000; + +/** The token endpoint gets its own, much shorter budget than `/agent`. */ +export const TOKEN_TIMEOUT_MS = 15_000; + +export interface TokenResponse { + status: number; + body: unknown; + /** Only when there was no response at all to speak for itself. */ + error?: string; +} + +export type TokenTransport = ( + url: string, + form: Record, +) => Promise; + +/** + * Two of the three ways authentication can fail, kept apart because a user cannot act on + * them otherwise. `rejected` is "your credentials are wrong"; `unreachable` is "auth is + * down". The third — Atlas refusing a token we successfully minted — is a server + * misconfiguration and lives in `relay.ts`, because it is discovered from `/agent`. + */ +export const AUTH_REJECTED_DETAIL = (status: number): string => + `Your BrowserStack credentials were rejected by BrowserStack auth (HTTP ${status}). ` + + `NOTHING REACHED THE AGENT — no request was made, no prompt appeared and nothing was ` + + `changed. Check BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY.`; + +export const AUTH_UNREACHABLE_DETAIL = + "Could not reach BrowserStack auth to sign in. NOTHING REACHED THE AGENT — no request " + + "was made, no prompt appeared and nothing was changed. This is a connectivity or " + + "auth-server problem, not a problem with your credentials."; + +export const AUTH_UNUSABLE_DETAIL = (status: number): string => + `BrowserStack auth answered HTTP ${status} without issuing a token. NOTHING REACHED THE ` + + `AGENT — no request was made, no prompt appeared and nothing was changed.`; + +interface CacheEntry { + token: string; + expiresAt: number; + /** Shared so N concurrent tool calls mint ONCE rather than N times. */ + inflight?: Promise; +} + +const cache = new Map(); + +/** Drop every cached token. For tests, and for a credential rotation. */ +export function resetTokenCache(): void { + cache.clear(); +} + +/** + * The cache key. + * + * Keyed on the access key so that ROTATING it mints immediately rather than leaving a + * revoked credential working until expiry — but on a SHA-256 of it, never the value, so the + * secret is not left sitting in a map key for the life of the process. + */ +function cacheKey(url: string, credentials: Credentials): string { + const digest = createHash("sha256").update(credentials.accessKey).digest("hex"); + return `${url} ${credentials.username} ${CENTRAL_SCOPE} ${digest}`; +} + +/** A fetch-based transport for the token endpoint. */ +export function fetchTokenTransport( + timeoutMs = TOKEN_TIMEOUT_MS, +): TokenTransport { + return async (url, form) => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + Accept: "application/json", + }, + body: new URLSearchParams(form).toString(), + redirect: "manual", + signal: controller.signal, + }); + let parsed: unknown = null; + try { + parsed = await response.json(); + } catch { + // An HTML error page behind any status. The caller only reads the status. + parsed = null; + } + return { status: response.status, body: parsed }; + } catch { + // DNS, TLS, timeout — all of them mean "no token". The reason is deliberately not + // carried: it can name the URL and, on some stacks, echo the request body. + return { status: 0, body: null, error: "auth could not be reached" }; + } finally { + clearTimeout(timer); + } + }; +} + +/** The exact form body of the `client_credentials` grant. */ +export function mintForm(credentials: Credentials): Record { + return { + grant_type: "client_credentials", + username: credentials.username, + access_key: credentials.accessKey, + scope: CENTRAL_SCOPE, + expires_in: String(REQUESTED_EXPIRES_IN), + }; +} + +async function mintOnce( + url: string, + credentials: Credentials, + transport: TokenTransport, +): Promise<{ token: string; lifetimeMs: number }> { + const response = await transport(url, mintForm(credentials)); + + if (response.status === 0) throw new AskError(AUTH_UNREACHABLE_DETAIL); + // ONLY THE STATUS. The body of a non-200 can echo the credential straight back. + if (response.status !== 200) throw new AskError(AUTH_REJECTED_DETAIL(response.status)); + + const body = + typeof response.body === "object" && response.body !== null + ? (response.body as Record) + : {}; + const token = body.access_token; + if (typeof token !== "string" || !token) { + throw new AskError(AUTH_UNUSABLE_DETAIL(response.status)); + } + + // Trust the SERVER's lifetime over what we asked for — it clamps to its own maximum, and + // caching for the requested hour when it granted less would hand out a dead token. + const granted = Number(body.expires_in); + const seconds = Number.isFinite(granted) && granted > 0 ? granted : REQUESTED_EXPIRES_IN; + return { token, lifetimeMs: seconds * 1000 }; +} + +/** + * Return a valid token, minting one only when the cache has nothing fresh. + * + * Minting per tool call would add a round trip to every request and make the token endpoint + * a hot dependency of the whole surface. + */ +export async function mintCentralToken( + url: string, + credentials: Credentials, + transport: TokenTransport, + now: number = Date.now(), +): Promise { + // Refused before any network call, and by name: these ARE the auth credential now, not + // merely attribution, so an empty one is our missing configuration rather than the user's + // rejected password, and must not read like one. + if (!credentials?.username || !credentials?.accessKey) { + throw new AskError( + "BrowserStack AI is not authenticated: BROWSERSTACK_USERNAME and " + + "BROWSERSTACK_ACCESS_KEY are required to sign in", + ); + } + + const key = cacheKey(url, credentials); + const entry = cache.get(key); + if (entry && entry.token && now < entry.expiresAt - REFRESH_SKEW_MS) { + return entry.token; + } + // Double-checked through a shared promise: concurrent callers await the same mint. + if (entry?.inflight) return entry.inflight; + + const pending = mintOnce(url, credentials, transport) + .then(({ token, lifetimeMs }) => { + cache.set(key, { token, expiresAt: now + lifetimeMs }); + logger.info( + "askBrowserstackAI: signed in as %s (lifetime %ss)", + credentials.username, + Math.round(lifetimeMs / 1000), + ); + return token; + }) + .catch((error) => { + // Never leave a rejected promise cached, or every later call inherits this failure. + cache.delete(key); + throw error; + }); + + cache.set(key, { token: "", expiresAt: 0, inflight: pending }); + return pending; +} diff --git a/src/tools/ask-browserstack/config.ts b/src/tools/ask-browserstack/config.ts index 55f6542..9a604e8 100644 --- a/src/tools/ask-browserstack/config.ts +++ b/src/tools/ask-browserstack/config.ts @@ -78,36 +78,30 @@ export function agentUrl(): string { } /** - * The shared delegation token `POST /agent` authenticates with (CONTRACT v1.2 §I). + * Where a central-OAuth JWT is minted (CONTRACT v1.2 §I, as amended by task 7). * - * `/agent` accepts exactly two credentials, both in `Authorization`: this shared token, which - * authenticates the CALLER only, or a BrowserStack central JWT, which also attests the acting - * user. There is no `Api-Token` path on this route. The shared token is what - * `authenticate()`'s own docstring describes for a backend caller like an MCP server, and the - * JWT path would mean minting a credential, which this work does not do. + * The shared `delegation.token` path is gone from Atlas, so a user-attested central JWT is + * now the only way in. This is the endpoint that issues one from a username and access key. * - * Same precedence rungs as the host, for the same reason: a deployment pointing at preprod - * must not be able to fall back to a token meant for somewhere else. - * - * THIS VALUE IS A SECRET. It is never logged, never returned in a result, and never named in - * an error message — only the env var that should hold it is. + * Same precedence rungs as the host, and refusing rather than guessing for the same reason: + * a deployment pointing at preprod must not sign in against production's auth server. */ -export function atlasToken(): string { - const explicit = process.env.ASK_BROWSERSTACK_ATLAS_TOKEN; +export function authTokenUrl(): string { + const explicit = process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL; if (explicit && explicit.trim()) return explicit.trim(); const environment = selectedEnvironment(); if (environment) { const suffixed = - process.env[`ASK_BROWSERSTACK_ATLAS_TOKEN_${environment.toUpperCase()}`]; + process.env[`ASK_BROWSERSTACK_AUTH_TOKEN_URL_${environment.toUpperCase()}`]; if (suffixed && suffixed.trim()) return suffixed.trim(); } throw new AskError( - "BrowserStack AI is not authenticated: set ASK_BROWSERSTACK_ATLAS_TOKEN" + + "BrowserStack AI has nowhere to sign in: set ASK_BROWSERSTACK_AUTH_TOKEN_URL" + (environment - ? ` or ASK_BROWSERSTACK_ATLAS_TOKEN_${environment.toUpperCase()}` + ? ` or ASK_BROWSERSTACK_AUTH_TOKEN_URL_${environment.toUpperCase()}` : "") + - " to the shared delegation token", + " to the BrowserStack OAuth token endpoint", ); } diff --git a/src/tools/ask-browserstack/register.ts b/src/tools/ask-browserstack/register.ts index 410ca89..50deba5 100644 --- a/src/tools/ask-browserstack/register.ts +++ b/src/tools/ask-browserstack/register.ts @@ -39,9 +39,10 @@ import { AskError, ELICITATION_TIMEOUT_MS, agentUrl, - atlasToken, + authTokenUrl, isEnabled, } from "./config.js"; +import { fetchTokenTransport, mintCentralToken } from "./central-oauth.js"; import { AgentTransport, Credentials, @@ -65,15 +66,20 @@ import { export interface AskDeps { /** Resolved per call: a deployment's host is configuration, not a constructor argument. */ agentUrl: () => string; - /** The shared delegation token, resolved per call and for the same reason. A secret. */ - atlasToken: () => string; + /** + * Sign in and return a bearer for `POST /agent`. Cached behind this, not minted per call. + * + * A function rather than a value because it is resolved per call for the same reason the + * host is, and because it can fail in ways a caller needs told apart. + */ + mintToken: () => Promise; /** * Read per call, not captured: the remote server rebuilds config per session, so a * captured credential would outlive the session it belongs to. * - * ONLY `username` is used, and only to attribute the run (`user_id`). The access key does - * NOT leave this process on this route: `/agent` has no use for it, so sending it would be - * exposure for nothing. Product calls are the place for `authHeaders`. + * These are now THE AUTH CREDENTIAL, not merely attribution: the access key is exchanged + * for a user-attested central JWT, which is what lets Atlas run the approved product call + * as the human rather than as a shared service account. */ credentialsFor: () => Credentials; transport?: AgentTransport; @@ -232,13 +238,18 @@ export function addAskBrowserstackAITool( try { const url = deps.agentUrl(); - const headers = agentHeaders(deps.atlasToken()); + // Signed in BEFORE the listener is opened and before the run starts. Minting + // lazily mid-call would put a token round-trip inside the window where a human is + // being prompted, and a mint that failed there would strand an open port. + const headers = agentHeaders(await deps.mintToken()); const body: AgentRequest = { task: query, product }; - // Attribution. The shared token authenticates the CALLER only, leaving - // `principal_verified=false`, so Atlas takes the acting user from the body — without - // it the run is unattributed, per-user limits cannot apply and the audit row cannot - // name who asked. Omitted ENTIRELY when unset, never sent as "". + // Attribution, and now belt-and-braces rather than the source of truth: the minted + // JWT is user-attested, so Atlas sets `principal_verified=True` and takes the acting + // user from signed claims instead of this field. It is still sent because it is part + // of the frozen wire format (CONTRACT v1.2 §3) and dropping it would be a one-sided + // change — but nothing should trust it, and Atlas no longer does. + // Omitted ENTIRELY when unset, never sent as "". const username = (deps.credentialsFor().username || "").trim(); if (username) body.user_id = username; @@ -296,17 +307,20 @@ export function addAskBrowserstackAIToolFromConfig( logger.info("askBrowserstackAI disabled by ASK_BROWSERSTACK_DISABLED"); return {}; } + const credentials = () => ({ + username: config["browserstack-username"], + accessKey: config["browserstack-access-key"], + }); + const tokenTransport = fetchTokenTransport(); return addAskBrowserstackAITool( server, { // Both resolved per call. An unconfigured host surfaces as a named error from the // tool rather than as a missing tool, so the cause is visible to whoever hits it. agentUrl, - atlasToken, - credentialsFor: () => ({ - username: config["browserstack-username"], - accessKey: config["browserstack-access-key"], - }), + mintToken: () => + mintCentralToken(authTokenUrl(), credentials(), tokenTransport), + credentialsFor: credentials, }, config, ); diff --git a/src/tools/ask-browserstack/relay.ts b/src/tools/ask-browserstack/relay.ts index df9674b..8143cfd 100644 --- a/src/tools/ask-browserstack/relay.ts +++ b/src/tools/ask-browserstack/relay.ts @@ -412,10 +412,11 @@ export function buildResult( * The token itself is NOT named here, only the variable that should hold it. */ export const UNAUTHENTICATED_DETAIL = - "BrowserStack AI rejected this server's credentials (HTTP 401). NOBODY DECLINED " + - "ANYTHING — the request never reached the agent, so no permission was sought and nothing " + - "was refused. The shared delegation token in ASK_BROWSERSTACK_ATLAS_TOKEN is missing, " + - "wrong, or not the one this environment expects."; + "Signing in with your BrowserStack credentials SUCCEEDED, but BrowserStack AI refused " + + "the resulting token (HTTP 401). YOUR CREDENTIALS ARE NOT THE PROBLEM — this is a " + + "server-side configuration one, most likely `delegation.required_scope` not matching the " + + "scope the token was minted with. NOBODY DECLINED ANYTHING: the request never reached " + + "the agent, so no permission was sought and nothing was refused."; /** * The `error` field, from whichever side has one. diff --git a/tests/tools/askBrowserstack.test.ts b/tests/tools/askBrowserstack.test.ts index 52bae49..a5c10e2 100644 --- a/tests/tools/askBrowserstack.test.ts +++ b/tests/tools/askBrowserstack.test.ts @@ -1,4 +1,4 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { CALLBACK_PATH, @@ -7,8 +7,17 @@ import { startCallbackListener, } from "../../src/tools/ask-browserstack/callback.js"; import { - AskError, atlasBaseUrl, atlasToken, + AskError, atlasBaseUrl, authTokenUrl, } from "../../src/tools/ask-browserstack/config.js"; +import { + AUTH_REJECTED_DETAIL, + AUTH_UNREACHABLE_DETAIL, + CENTRAL_SCOPE, + REFRESH_SKEW_MS, + mintCentralToken, + mintForm, + resetTokenCache, +} from "../../src/tools/ask-browserstack/central-oauth.js"; import { agentHeaders } from "../../src/tools/ask-browserstack/egress.js"; import { approvalOutcome, @@ -19,6 +28,7 @@ import { elicitationMessage, looksLikeDelegationResult, neverReachedAgent, + UNAUTHENTICATED_DETAIL, } from "../../src/tools/ask-browserstack/relay.js"; import { ApprovalRecord } from "../../src/tools/ask-browserstack/types.js"; @@ -598,51 +608,198 @@ describe("POST /agent headers — CONTRACT v1.2 §4", () => { it("is exactly three headers, and Api-Token is not one of them", () => { // /agent has no Api-Token path, and that header carries the user's access key. Asserting // the exact key set is what stops it being reintroduced by a helpful future edit. - expect(agentHeaders("shared-delegation-token")).toEqual({ - Authorization: "Bearer shared-delegation-token", + expect(agentHeaders("minted.central.jwt")).toEqual({ + Authorization: "Bearer minted.central.jwt", "Content-Type": "application/json", "request-source": "ai-chatbot", }); }); }); -describe("the delegation token", () => { +describe("where we sign in", () => { const saved = { ...process.env }; afterEach(() => { process.env = { ...saved }; }); - it("refuses by name, naming the variable and never a value", () => { - delete process.env.ASK_BROWSERSTACK_ATLAS_TOKEN; + it("refuses by name rather than guessing an auth host", () => { + delete process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL; delete process.env.ASK_BROWSERSTACK_ENV; delete process.env.CAPABILITY_REGISTRY_ENV; - expect(() => atlasToken()).toThrow(AskError); - expect(() => atlasToken()).toThrow(/ASK_BROWSERSTACK_ATLAS_TOKEN/); + expect(() => authTokenUrl()).toThrow(AskError); + expect(() => authTokenUrl()).toThrow(/ASK_BROWSERSTACK_AUTH_TOKEN_URL/); }); - it("treats a blank token as unset rather than sending `Bearer `", () => { - process.env.ASK_BROWSERSTACK_ATLAS_TOKEN = " "; - delete process.env.ASK_BROWSERSTACK_ENV; - delete process.env.CAPABILITY_REGISTRY_ENV; - expect(() => atlasToken()).toThrow(AskError); + it("lets the environment pick it, so preprod cannot sign in against production", () => { + delete process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL; + process.env.ASK_BROWSERSTACK_ENV = "preprod"; + process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL_PREPROD = " https://auth-pp.example/t "; + expect(authTokenUrl()).toBe("https://auth-pp.example/t"); }); - it("takes the environment's token when one is named, and trims it", () => { - delete process.env.ASK_BROWSERSTACK_ATLAS_TOKEN; + it("lets an explicit endpoint win", () => { process.env.ASK_BROWSERSTACK_ENV = "preprod"; - process.env.ASK_BROWSERSTACK_ATLAS_TOKEN_PREPROD = " preprod-token "; - expect(atlasToken()).toBe("preprod-token"); + process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL_PREPROD = "https://auth-pp.example/t"; + process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL = "https://auth.example/t"; + expect(authTokenUrl()).toBe("https://auth.example/t"); }); +}); - it("lets an explicit token win", () => { - process.env.ASK_BROWSERSTACK_ENV = "preprod"; - process.env.ASK_BROWSERSTACK_ATLAS_TOKEN_PREPROD = "preprod-token"; - process.env.ASK_BROWSERSTACK_ATLAS_TOKEN = "explicit-token"; - expect(atlasToken()).toBe("explicit-token"); +describe("minting a central JWT", () => { + const URL_ = "https://auth.example/oauth2/v2/token"; + const CREDS = { username: "ing_Xx", accessKey: "SECRET" }; + const OK = { status: 200, body: { access_token: "jwt.aaa", expires_in: 3600 } }; + + beforeEach(() => resetTokenCache()); + afterEach(() => resetTokenCache()); + + function recording(response: any = OK) { + const seen: { url: string; form: Record }[] = []; + const transport = async (url: string, form: Record) => { + seen.push({ url, form }); + return typeof response === "function" ? response() : response; + }; + return { seen, transport }; + } + + it("sends the client_credentials grant verbatim, with BOTH scope parts", () => { + expect(mintForm(CREDS)).toEqual({ + grant_type: "client_credentials", + username: "ing_Xx", + access_key: "SECRET", + scope: "oauth_user_profile central_ai_s2s", + expires_in: "3600", + }); + // Load-bearing in both directions: `central_ai_s2s` is what Atlas matches on, and the + // endpoint will not issue it to a username+access_key unless `oauth_user_profile` is + // paired with it. + expect(CENTRAL_SCOPE.split(" ")).toEqual(["oauth_user_profile", "central_ai_s2s"]); + }); + + it("returns the token the endpoint issued", async () => { + const { seen, transport } = recording(); + expect(await mintCentralToken(URL_, CREDS, transport, 0)).toBe("jwt.aaa"); + expect(seen).toHaveLength(1); + expect(seen[0].url).toBe(URL_); + }); + + it("does not re-mint inside the cache window", async () => { + const { seen, transport } = recording(); + await mintCentralToken(URL_, CREDS, transport, 0); + await mintCentralToken(URL_, CREDS, transport, 60_000); + await mintCentralToken(URL_, CREDS, transport, 1_000_000); + expect(seen).toHaveLength(1); + }); + + it("refreshes once the token is inside the skew", async () => { + const { seen, transport } = recording(); + await mintCentralToken(URL_, CREDS, transport, 0); + // Expires at 3_600_000; the skew is the whole /agent budget plus a minute, because + // Atlas holds this token for the life of the run and re-uses it for product egress. + expect(REFRESH_SKEW_MS).toBe(390_000); + await mintCentralToken(URL_, CREDS, transport, 3_600_000 - REFRESH_SKEW_MS + 1); + expect(seen).toHaveLength(2); + }); + + it("mints ONCE for concurrent callers rather than once each", async () => { + const { seen, transport } = recording(); + const answers = await Promise.all([ + mintCentralToken(URL_, CREDS, transport, 0), + mintCentralToken(URL_, CREDS, transport, 0), + mintCentralToken(URL_, CREDS, transport, 0), + ]); + expect(seen).toHaveLength(1); + expect(answers).toEqual(["jwt.aaa", "jwt.aaa", "jwt.aaa"]); + }); + + it("mints again when the access key rotates, rather than serving a revoked one", async () => { + const { seen, transport } = recording(); + await mintCentralToken(URL_, CREDS, transport, 0); + await mintCentralToken(URL_, { ...CREDS, accessKey: "ROTATED" }, transport, 0); + expect(seen).toHaveLength(2); + }); + + it("trusts the lifetime the SERVER granted, not the one we asked for", async () => { + const { seen, transport } = recording({ + status: 200, body: { access_token: "jwt.aaa", expires_in: 600 }, + }); + await mintCentralToken(URL_, CREDS, transport, 0); + // Granted 600s, so it is already inside the 390s skew at t=300s. + await mintCentralToken(URL_, CREDS, transport, 300_000); + expect(seen).toHaveLength(2); + }); + + it("surfaces ONLY the status when the endpoint refuses, never the body", async () => { + // The real endpoint's error body echoes the credential straight back. + const transport = async () => ({ + status: 401, + body: { error: "invalid_client", error_description: "access_key SECRET is invalid" }, + }); + await expect(mintCentralToken(URL_, CREDS, transport, 0)).rejects.toThrow(AskError); + const message = await mintCentralToken(URL_, CREDS, transport, 0).catch((e) => e.message); + expect(message).toMatch(/rejected by BrowserStack auth \(HTTP 401\)/); + expect(message).not.toContain("SECRET"); + expect(message).not.toContain("invalid_client"); + expect(message).not.toContain("access_key"); + }); + + it("says unreachable, distinctly from refused", async () => { + const transport = async () => ({ status: 0, body: null, error: "auth could not be reached" }); + const message = await mintCentralToken(URL_, CREDS, transport, 0).catch((e) => e.message); + expect(message).toBe(AUTH_UNREACHABLE_DETAIL); + expect(message).not.toMatch(/rejected/); + }); + + it("says so when a 200 carries no access_token", async () => { + const transport = async () => ({ status: 200, body: { token_type: "Bearer" } }); + const message = await mintCentralToken(URL_, CREDS, transport, 0).catch((e) => e.message); + expect(message).toMatch(/without issuing a token/); + }); + + it("does not cache a failure", async () => { + let calls = 0; + const transport = async () => { + calls += 1; + return calls === 1 ? { status: 500, body: null } : OK; + }; + await mintCentralToken(URL_, CREDS, transport, 0).catch(() => undefined); + expect(await mintCentralToken(URL_, CREDS, transport, 0)).toBe("jwt.aaa"); + expect(calls).toBe(2); + }); + + it("refuses before any network call when a credential is missing", async () => { + const { seen, transport } = recording(); + for (const creds of [ + { username: "", accessKey: "SECRET" }, + { username: "ing_Xx", accessKey: "" }, + ]) { + const message = await mintCentralToken(URL_, creds, transport, 0).catch((e) => e.message); + expect(message).toMatch(/BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY/); + // Our missing configuration, not the user's password being wrong. + expect(message).not.toMatch(/rejected/); + } + expect(seen).toHaveLength(0); + }); + + it("keeps the three auth failures readable as three different problems", () => { + const rejected = AUTH_REJECTED_DETAIL(401); + const unreachable = AUTH_UNREACHABLE_DETAIL; + const refusedByAtlas = UNAUTHENTICATED_DETAIL; + expect(new Set([rejected, unreachable, refusedByAtlas]).size).toBe(3); + // "your credentials are wrong" vs "auth is down" vs "the server is misconfigured" + expect(rejected).toMatch(/credentials were rejected/); + expect(unreachable).toMatch(/Could not reach BrowserStack auth/); + expect(refusedByAtlas).toMatch(/SUCCEEDED/); + expect(refusedByAtlas).toMatch(/YOUR CREDENTIALS ARE NOT THE PROBLEM/); + // None of them is a permission denial. + for (const message of [rejected, unreachable, refusedByAtlas]) { + expect(message).toMatch(/NOTHING REACHED THE AGENT|never reached the agent/); + } }); }); + describe("pre-run refusals — the request never reached the agent (D3)", () => { // Atlas omits its permission_relay verdict on every refusal that dies before the // delegation layer, so "no verdict" alone cannot be read as "an old Atlas". diff --git a/tests/tools/askBrowserstackE2E.test.ts b/tests/tools/askBrowserstackE2E.test.ts index e71729a..41b80ff 100644 --- a/tests/tools/askBrowserstackE2E.test.ts +++ b/tests/tools/askBrowserstackE2E.test.ts @@ -3,6 +3,7 @@ import { ErrorCode, McpError } from "@modelcontextprotocol/sdk/types.js"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AskError } from "../../src/tools/ask-browserstack/config.js"; +import { resetTokenCache } from "../../src/tools/ask-browserstack/central-oauth.js"; import { addAskBrowserstackAITool } from "../../src/tools/ask-browserstack/register.js"; /** Captured before anything stubs the global, so the loopback hop stays real. */ @@ -14,6 +15,8 @@ const CONFIG = { } as any; const PERM_A = "perm-" + "a".repeat(32); +const AUTH_URL = "https://auth.example/oauth2/v2/token"; +const MINTED = "minted.central.jwt"; const PERM_B = "perm-" + "b".repeat(32); interface AtlasCall { @@ -31,11 +34,23 @@ function atlas(options: { token?: (real: string) => string; payload?: (decisions: any[]) => unknown; throws?: boolean; + authStatus?: number; }) { const calls: AtlasCall[] = []; const decisions: any[] = []; + const mints: Record[] = []; const stub = async (url: string, init: any) => { + if (String(url) === AUTH_URL) { + mints.push(Object.fromEntries(new URLSearchParams(init.body))); + return { + status: options.authStatus ?? 200, + headers: { get: () => "application/json" }, + json: async () => (options.authStatus && options.authStatus !== 200 + ? { error: "invalid_client", error_description: "access_key SECRET is invalid" } + : { access_token: MINTED, expires_in: 3600, token_type: "Bearer" }), + }; + } const body = JSON.parse(init.body); calls.push({ url: String(url), headers: init.headers, body }); if (options.throws) throw new Error("connection reset"); @@ -64,7 +79,24 @@ function atlas(options: { }; vi.stubGlobal("fetch", stub); - return { calls, decisions }; + return { calls, decisions, mints }; +} + +/** + * A bare fetch stub that still signs in. Every path to `/agent` now mints first, so a stub + * that only knows about `/agent` would have the mint land on it instead. + */ +function withAuth(agent: (url: string, init: any) => Promise) { + return async (url: string, init: any) => { + if (String(url) === AUTH_URL) { + return { + status: 200, + headers: { get: () => "application/json" }, + json: async () => ({ access_token: MINTED, expires_in: 3600 }), + }; + } + return agent(url, init); + }; } async function buildServer() { @@ -95,7 +127,8 @@ async function call(tools: Record, args = { product: "tm", query: " describe("askBrowserstackAI, end to end through the server factory", () => { beforeEach(() => { process.env.ASK_BROWSERSTACK_ATLAS_URL = "https://atlas.example"; - process.env.ASK_BROWSERSTACK_ATLAS_TOKEN = "shared-delegation-token"; + process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL = AUTH_URL; + resetTokenCache(); delete process.env.ASK_BROWSERSTACK_DISABLED; delete process.env.ASK_BROWSERSTACK_ENV; vi.resetModules(); @@ -103,7 +136,8 @@ describe("askBrowserstackAI, end to end through the server factory", () => { afterEach(() => { delete process.env.ASK_BROWSERSTACK_ATLAS_URL; - delete process.env.ASK_BROWSERSTACK_ATLAS_TOKEN; + delete process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL; + resetTokenCache(); vi.unstubAllGlobals(); vi.restoreAllMocks(); }); @@ -142,7 +176,7 @@ describe("askBrowserstackAI, end to end through the server factory", () => { // 1. the request: authenticated with the SHARED DELEGATION TOKEN, attributed, relay // offered on loopback with a per-run token expect(stub.calls[0].url).toBe("https://atlas.example/agent"); - expect(stub.calls[0].headers.Authorization).toBe("Bearer shared-delegation-token"); + expect(stub.calls[0].headers.Authorization).toBe(`Bearer ${MINTED}`); // The EXACT key set. /agent has no Api-Token path, and that header carries the user's // access key — this assertion is what stops it creeping back in. expect(Object.keys(stub.calls[0].headers).sort()) @@ -449,7 +483,7 @@ describe("askBrowserstackAI, end to end through the server factory", () => { // Atlas answers 502 when a delegation RAN and a step then failed. One prompt was // shown and approved; the write did not land. const realFetchLocal = realFetch; - vi.stubGlobal("fetch", async (url: string, init: any) => { + vi.stubGlobal("fetch", withAuth(async (_url: string, init: any) => { const body = JSON.parse(init.body); await realFetchLocal(body.permission_relay.callback_url, { method: "POST", @@ -475,7 +509,7 @@ describe("askBrowserstackAI, end to end through the server factory", () => { permission_relay: { used: true, reason: "" }, }), }; - }); + })); const { payload } = await call(server.getTools()); @@ -568,22 +602,27 @@ describe("askBrowserstackAI, end to end through the server factory", () => { }); describe("POST /agent authentication — CONTRACT v1.2", () => { - it("omits user_id entirely, never as \"\", when no username is configured", async () => { - const { BrowserStackMcpServer } = await import("../../src/server-factory.js"); - const server = new BrowserStackMcpServer({ - "browserstack-username": "", - "browserstack-access-key": "", - } as any); - fakeClient(server.getInstance(), { roots: {} }, []); - const stub = atlas({}); + it("omits user_id entirely, never as \"\", when no username is available", async () => { + // Driven through the seam: with central auth a blank username cannot sign in at all, + // so this branch is no longer reachable from the factory — but the wire rule still + // holds and must stay covered. + const mcp = new McpServer({ name: "t", version: "0" }); + const transport = vi.fn(async () => ({ status: 200, body: { status: "ok", answer: "" } })); + const tools = addAskBrowserstackAITool(mcp, { + agentUrl: () => "https://atlas.example/agent", + mintToken: async () => MINTED, + credentialsFor: () => ({ username: "", accessKey: "" }), + transport: transport as never, + }); - await call(server.getTools()); - expect("user_id" in stub.calls[0].body).toBe(false); - expect(Object.keys(stub.calls[0].body).sort()).toEqual(["product", "task"]); + await call(tools); + const body = (transport.mock.calls[0] as any)[2]; + expect("user_id" in body).toBe(false); + expect(Object.keys(body).sort()).toEqual(["product", "task"]); }); - it("refuses by name when the token is unset, and never leaks it", async () => { - delete process.env.ASK_BROWSERSTACK_ATLAS_TOKEN; + it("refuses by name when there is nowhere to sign in", async () => { + delete process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL; const fetchSpy = vi.fn(); vi.stubGlobal("fetch", fetchSpy); const server = await buildServer(); @@ -591,11 +630,41 @@ describe("askBrowserstackAI, end to end through the server factory", () => { const { result, payload } = await call(server.getTools()); expect(result.isError).toBe(true); - expect(payload.error).toMatch(/ASK_BROWSERSTACK_ATLAS_TOKEN/); + expect(payload.error).toMatch(/ASK_BROWSERSTACK_AUTH_TOKEN_URL/); expect(fetchSpy).not.toHaveBeenCalled(); }); - it("never puts the token in the result, on success or on failure", async () => { + it("mints the token with the exact client_credentials grant", async () => { + const server = await buildServer(); + fakeClient(server.getInstance(), { elicitation: {} }, []); + const stub = atlas({}); + + await call(server.getTools()); + expect(stub.mints).toHaveLength(1); + expect(stub.mints[0]).toEqual({ + grant_type: "client_credentials", + username: "ing_Xx", + access_key: "SECRET", + // BOTH parts. `central_ai_s2s` alone is refused by the endpoint; without it Atlas + // refuses the token. + scope: "oauth_user_profile central_ai_s2s", + expires_in: "3600", + }); + }); + + it("does not re-mint on a second call inside the cache window", async () => { + const server = await buildServer(); + fakeClient(server.getInstance(), { elicitation: {} }, []); + const stub = atlas({}); + + await call(server.getTools()); + await call(server.getTools()); + await call(server.getTools()); + expect(stub.calls).toHaveLength(3); // three runs... + expect(stub.mints).toHaveLength(1); // ...one sign-in + }); + + it("never puts the access key or the minted token in the result", async () => { const server = await buildServer(); fakeClient(server.getInstance(), { elicitation: {} }, [ { action: "accept", content: { confirm: true } }, @@ -604,26 +673,81 @@ describe("askBrowserstackAI, end to end through the server factory", () => { const { result } = await call(server.getTools()); // The whole serialised result, not just the fields we happen to check. - expect(result.content[0].text).not.toContain("shared-delegation-token"); + expect(result.content[0].text).not.toContain("SECRET"); + expect(result.content[0].text).not.toContain(MINTED); + }); + + it("says the credentials were rejected, and NEVER echoes the auth body back", async () => { + const server = await buildServer(); + const elicit = fakeClient(server.getInstance(), { elicitation: {} }, []); + // The endpoint's own error body echoes the access key straight back. + const stub = atlas({ authStatus: 401 }); + + const { result, payload } = await call(server.getTools()); + + expect(payload.error).toMatch(/credentials were rejected by BrowserStack auth \(HTTP 401\)/); + expect(payload.error).toMatch(/BROWSERSTACK_ACCESS_KEY/); + // Only the status crosses. Not the body, not the key it contained. + expect(result.content[0].text).not.toContain("SECRET"); + expect(result.content[0].text).not.toContain("invalid_client"); + // Never got as far as Atlas, let alone a prompt. + expect(stub.calls).toHaveLength(0); + expect(elicit).not.toHaveBeenCalled(); + expect(payload.permission_relay.reason).toBe("not_reached"); + expect(payload.applied_before_stop).toBeNull(); + }); + + it("says auth was unreachable, distinctly from a rejected credential", async () => { + const server = await buildServer(); + const elicit = fakeClient(server.getInstance(), { elicitation: {} }, []); + vi.stubGlobal("fetch", async () => { + throw new Error("ECONNREFUSED"); + }); + + const { payload } = await call(server.getTools()); + expect(payload.error).toMatch(/Could not reach BrowserStack auth/); + expect(payload.error).not.toMatch(/credentials were rejected/); + expect(elicit).not.toHaveBeenCalled(); + expect(payload.permission_relay.reason).toBe("not_reached"); + }); + + it("refuses before any network call when a credential is missing", async () => { + const { BrowserStackMcpServer } = await import("../../src/server-factory.js"); + const server = new BrowserStackMcpServer({ + "browserstack-username": "ing_Xx", + "browserstack-access-key": "", + } as any); + fakeClient(server.getInstance(), { elicitation: {} }, []); + const fetchSpy = vi.fn(); + vi.stubGlobal("fetch", fetchSpy); + + const { payload } = await call(server.getTools()); + expect(payload.error).toMatch(/BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY/); + // Our missing configuration, not the user's password being wrong. + expect(payload.error).not.toMatch(/rejected/); + expect(fetchSpy).not.toHaveBeenCalled(); }); it("reads a 401 as rejected credentials, not as anyone declining", async () => { const server = await buildServer(); const elicit = fakeClient(server.getInstance(), { elicitation: {} }, []); - vi.stubGlobal("fetch", async () => ({ + vi.stubGlobal("fetch", withAuth(async () => ({ status: 401, headers: { get: () => "application/json" }, // Exactly what Atlas answers a bad bearer with: no `error` string of its own. json: async () => ({ detail: "unauthorized" }), - })); + }))); const { result, payload } = await call(server.getTools()); expect(payload.status).toBe("error"); expect(result.isError).toBe(true); - expect(payload.error).toMatch(/rejected this server's credentials/); + // The THIRD failure mode: we signed in fine, Atlas refused the token. It must not + // read as "your password is wrong" — it is a server misconfiguration. + expect(payload.error).toMatch(/Signing in with your BrowserStack credentials SUCCEEDED/); + expect(payload.error).toMatch(/YOUR CREDENTIALS ARE NOT THE PROBLEM/); + expect(payload.error).toMatch(/delegation\.required_scope/); expect(payload.error).toMatch(/NOBODY DECLINED ANYTHING/); - expect(payload.error).toMatch(/ASK_BROWSERSTACK_ATLAS_TOKEN/); // Nothing was ever asked, so nothing can look like a refusal. expect(elicit).not.toHaveBeenCalled(); expect(payload.approvals).toEqual([]); @@ -660,11 +784,11 @@ describe("askBrowserstackAI, end to end through the server factory", () => { const server = await buildServer(); const elicit = fakeClient(server.getInstance(), { elicitation: {} }, []); // Exactly what Atlas answers with before the delegation layer: a bare detail. - vi.stubGlobal("fetch", async () => ({ + vi.stubGlobal("fetch", withAuth(async () => ({ status, headers: { get: () => "application/json" }, json: async () => ({ detail }), - })); + }))); const { result, payload } = await call(server.getTools()); @@ -697,12 +821,12 @@ describe("askBrowserstackAI, end to end through the server factory", () => { expect(payload.approvals[0]).toMatchObject({ decision: "deny", reason: "declined" }); }); - it("lets the named environment pick the token, so preprod cannot borrow prod's", async () => { - delete process.env.ASK_BROWSERSTACK_ATLAS_TOKEN; + it("signs in against the named environment, so preprod cannot use prod's auth", async () => { process.env.ASK_BROWSERSTACK_ENV = "preprod"; process.env.ASK_BROWSERSTACK_ATLAS_URL_PREPROD = "https://atlas-preprod.example"; - process.env.ASK_BROWSERSTACK_ATLAS_TOKEN_PREPROD = "preprod-token"; + process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL_PREPROD = AUTH_URL; delete process.env.ASK_BROWSERSTACK_ATLAS_URL; + delete process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL; try { const server = await buildServer(); fakeClient(server.getInstance(), { roots: {} }, []); @@ -710,11 +834,12 @@ describe("askBrowserstackAI, end to end through the server factory", () => { await call(server.getTools()); expect(stub.calls[0].url).toBe("https://atlas-preprod.example/agent"); - expect(stub.calls[0].headers.Authorization).toBe("Bearer preprod-token"); + expect(stub.calls[0].headers.Authorization).toBe(`Bearer ${MINTED}`); + expect(stub.mints).toHaveLength(1); } finally { delete process.env.ASK_BROWSERSTACK_ENV; delete process.env.ASK_BROWSERSTACK_ATLAS_URL_PREPROD; - delete process.env.ASK_BROWSERSTACK_ATLAS_TOKEN_PREPROD; + delete process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL_PREPROD; } }); }); @@ -741,10 +866,10 @@ describe("askBrowserstackAI, against the injected seam", () => { const transport = vi.fn(); const tools = addAskBrowserstackAITool(mcp, { agentUrl: () => "https://atlas.example/agent", - atlasToken: () => { + mintToken: async () => { throw new AskError( - "BrowserStack AI is not authenticated: set ASK_BROWSERSTACK_ATLAS_TOKEN to the " + - "shared delegation token", + "BrowserStack AI is not authenticated: BROWSERSTACK_USERNAME and " + + "BROWSERSTACK_ACCESS_KEY are required to sign in", ); }, credentialsFor: () => ({ username: "u", accessKey: "k" }), @@ -754,7 +879,7 @@ describe("askBrowserstackAI, against the injected seam", () => { const { payload } = await call(tools); expect(payload.ok).toBe(false); - expect(payload.error).toMatch(/ASK_BROWSERSTACK_ATLAS_TOKEN/); + expect(payload.error).toMatch(/BROWSERSTACK_ACCESS_KEY/); expect(transport).not.toHaveBeenCalled(); }); @@ -765,7 +890,7 @@ describe("askBrowserstackAI, against the injected seam", () => { const transport = vi.fn(async () => ({ status: 200, body: { status: "ok", answer: "" } })); const tools = addAskBrowserstackAITool(mcp, { agentUrl: () => "https://atlas.example/agent", - atlasToken: () => "shared-delegation-token", + mintToken: async () => MINTED, credentialsFor: () => ({ username: "ing_Xx", accessKey: "" }), transport: transport as never, startListener: (async () => ({ url: "x", token: "y", close: async () => {} })) as never, @@ -783,7 +908,7 @@ describe("askBrowserstackAI, against the injected seam", () => { const close = vi.fn(async () => {}); const tools = addAskBrowserstackAITool(mcp, { agentUrl: () => "https://atlas.example/agent", - atlasToken: () => "shared-delegation-token", + mintToken: async () => MINTED, credentialsFor: () => ({ username: "u", accessKey: "k" }), transport: (async () => { throw new Error("boom"); From df77700868bb602a443b0c888052df75c72547fb Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Mon, 24 Aug 2026 19:17:23 +0530 Subject: [PATCH 13/31] Request ai_agent_notify, and fail loudly if it will not be issued MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The requested scope becomes oauth_user_profile ai_agent_notify, moving with Atlas's required_scope. Only the second half changes; the first stays for the reason it was always there, as what makes the pair obtainable through the username and access key flow at all. The comment on the constant now records why this is a narrower door than the one it replaces, from the merged railsApp change: ai_agent_notify is documented as client_id/secret auth, the username+access_key allow list is still only user_management and oauth_user_profile, it carries a new application registration gate, and railsApp defines it as the product-to-agent direction while we use it as an agent-to-Atlas inbound credential. The scope it replaces was deliberately excluded from that gate. Those are the facts that decide whether this can be minted at all, so they belong next to the string rather than in a report nobody reads at three in the morning. Because it may simply not be issuable, the refusal is now told apart from a rejected credential. The two need entirely different fixes — provisioning versus a password — and one message for both sends someone to the wrong place. The refusal names the scope, says outright that the credentials are not the problem, and says that this server will not quietly retry with a weaker one. It does not: a silent downgrade to a different authorization is precisely the thing nobody notices until it matters, and every refusal shape is asserted to make exactly one attempt carrying exactly the chosen scope. Classifying the refusal means reading the failure body, which the credential rule forbids surfacing. So only the OAuth2 error code is consulted, only when it is one of the fixed spec tokens, which cannot carry a credential the way the free-text description demonstrably can — and it is discarded after classifying. Nothing from the body reaches a message; where no usable code exists the status decides, since a bad request is a 400 and a bad caller is a 401. --- src/tools/ask-browserstack/central-oauth.ts | 99 ++++++++++++++++--- tests/tools/askBrowserstack.test.ts | 103 +++++++++++++++++--- tests/tools/askBrowserstackE2E.test.ts | 35 ++++++- 3 files changed, 206 insertions(+), 31 deletions(-) diff --git a/src/tools/ask-browserstack/central-oauth.ts b/src/tools/ask-browserstack/central-oauth.ts index bcc5a6e..9bcff22 100644 --- a/src/tools/ask-browserstack/central-oauth.ts +++ b/src/tools/ask-browserstack/central-oauth.ts @@ -21,19 +21,35 @@ import { AGENT_TIMEOUT_MS, AskError } from "./config.js"; import { Credentials } from "./egress.js"; /** - * BOTH PARTS ARE REQUIRED — do not "tidy" this into one scope. + * BOTH PARTS ARE REQUIRED, AND THERE IS NO FALLBACK TO ANOTHER SCOPE. * - * `central_ai_s2s` is what Atlas matches on (`delegation.required_scope`, checked as exact - * membership of the token's `scopes` claim in `web/oauth.py`). But it is a client_id+secret - * scope, and asking for it ALONE with a username+access_key is rejected outright. It becomes - * obtainable only when paired with `oauth_user_profile` — see the note at `web/oauth.py:449`: - * "today any user can obtain `central_ai_s2s` by pairing it with `oauth_user_profile`, - * because the railsApp guard uses `none?` where it means `all?`". + * `oauth_user_profile` stays because it is what makes the pair obtainable through the + * username+access_key flow at all. `ai_agent_notify` is what Atlas matches on + * (`delegation.required_scope`, checked as exact membership of the token's `scopes` claim in + * `web/oauth.py`); both halves move together with Atlas. * - * So the pair is load-bearing in both directions: drop `central_ai_s2s` and Atlas refuses the - * token; drop `oauth_user_profile` and the endpoint refuses to issue it. + * THIS SCOPE MAY SIMPLY NOT BE ISSUABLE TO US, and the reasons are worth stating rather than + * discovering. From the merged `browserstack/railsApp#175367` (2026-08-24): + * + * - `ai_agent_notify` is documented there as CLIENT_ID/SECRET auth, and + * `USERNAME_ACCESS_KEY_ONLY_SCOPES` remains only `user_management, oauth_user_profile`. + * We are on the username+access_key flow, which those restrictions are not written for. + * - It is additionally covered by a new + * `APP_REGISTERED_SCOPE_REQUIRED = %w[ai_agent ai_agent_notify]` gate, requiring the + * calling APPLICATION to be registered for it — though that gate sits in the + * `client_id + client_secret` path, not ours. + * - railsApp defines it as the PRODUCT -> AGENT direction: "a product reporting progress + * back to an AI agent for work the agent dispatched." We use it in the opposite + * direction, as an agent -> Atlas inbound credential. + * - `central_ai_s2s`, which this replaces, was deliberately EXCLUDED from that new gate. + * + * So this is strictly more restricted than what it replaces. If the endpoint refuses it, that + * is a PROVISIONING problem — the scope is not available to this credential type or this + * application — and it is reported as one, naming the scope. It is never retried with a + * different scope: a silent downgrade to a different authorization is exactly the kind of + * thing nobody notices until it matters. */ -export const CENTRAL_SCOPE = "oauth_user_profile central_ai_s2s"; +export const CENTRAL_SCOPE = "oauth_user_profile ai_agent_notify"; /** What we ask for. The endpoint clamps to its own maximum, so the response wins. */ export const REQUESTED_EXPIRES_IN = 3600; @@ -65,11 +81,54 @@ export type TokenTransport = ( ) => Promise; /** - * Two of the three ways authentication can fail, kept apart because a user cannot act on - * them otherwise. `rejected` is "your credentials are wrong"; `unreachable` is "auth is - * down". The third — Atlas refusing a token we successfully minted — is a server - * misconfiguration and lives in `relay.ts`, because it is discovered from `/agent`. + * The OAuth2 error codes we are willing to read out of a failure body. + * + * `error` is a fixed enum token in the spec, so it cannot carry a credential; `error_description` + * is free text and demonstrably CAN ("access_key is invalid"), which is why only the + * code is ever looked at and only when it is one of these. Anything else is ignored entirely + * and the classification falls back to the status. */ +const SCOPE_ERROR_CODES = ["invalid_scope", "unauthorized_client", "invalid_request"]; +const CREDENTIAL_ERROR_CODES = ["invalid_client", "invalid_grant", "access_denied"]; + +/** + * Was this refusal about the SCOPE or about the CREDENTIAL? + * + * The two need completely different fixes — provisioning versus a password — so collapsing + * them into one message sends someone to the wrong place entirely. Our form has five fields + * and four of them are constants, so a refusal of the REQUEST (as opposed to the caller) can + * only really be about the scope. + * + * Nothing from the body is ever surfaced; the code is used to classify and then discarded. + */ +export function refusalIsAboutScope(status: number, body: unknown): boolean { + const payload = + typeof body === "object" && body !== null ? (body as Record) : {}; + const code = typeof payload.error === "string" ? payload.error : ""; + if (SCOPE_ERROR_CODES.includes(code)) return true; + if (CREDENTIAL_ERROR_CODES.includes(code)) return false; + // No usable code. OAuth2 answers a bad REQUEST with 400 and a bad CLIENT with 401/403, so + // the status is the next best evidence. + return status === 400; +} + +/** + * The ways authentication can fail, kept apart because a user cannot act on them otherwise. + * + * `scope refused` is a provisioning problem; `rejected` is "your credentials are wrong"; + * `unreachable` is "auth is down". A fourth — Atlas refusing a token we minted successfully — + * is a server misconfiguration and lives in `relay.ts`, because it is discovered from + * `/agent`. Four different fixes, so four different sentences. + */ +export const AUTH_SCOPE_REFUSED_DETAIL = (status: number): string => + `BrowserStack auth would not issue a token for the scope "${CENTRAL_SCOPE}" (HTTP ${status}). ` + + `YOUR CREDENTIALS ARE NOT THE PROBLEM — this is a provisioning problem: \`ai_agent_notify\` ` + + `is documented as a client_id/secret scope, it is not in the username+access_key allow ` + + `list, and it carries an application-registration requirement. It has to be enabled for ` + + `this account or application; a different password will not help, and this server will ` + + `NOT quietly retry with a weaker scope. NOTHING REACHED THE AGENT — no request was made, ` + + `no prompt appeared and nothing was changed.`; + export const AUTH_REJECTED_DETAIL = (status: number): string => `Your BrowserStack credentials were rejected by BrowserStack auth (HTTP ${status}). ` + `NOTHING REACHED THE AGENT — no request was made, no prompt appeared and nothing was ` + @@ -165,8 +224,16 @@ async function mintOnce( const response = await transport(url, mintForm(credentials)); if (response.status === 0) throw new AskError(AUTH_UNREACHABLE_DETAIL); - // ONLY THE STATUS. The body of a non-200 can echo the credential straight back. - if (response.status !== 200) throw new AskError(AUTH_REJECTED_DETAIL(response.status)); + if (response.status !== 200) { + // ONLY THE STATUS CROSSES. The body is read solely to tell a provisioning problem from a + // credential one, and nothing out of it is ever put in the message — a non-200 body can + // echo the access key straight back. + throw new AskError( + refusalIsAboutScope(response.status, response.body) + ? AUTH_SCOPE_REFUSED_DETAIL(response.status) + : AUTH_REJECTED_DETAIL(response.status), + ); + } const body = typeof response.body === "object" && response.body !== null diff --git a/tests/tools/askBrowserstack.test.ts b/tests/tools/askBrowserstack.test.ts index a5c10e2..ad8b7f4 100644 --- a/tests/tools/askBrowserstack.test.ts +++ b/tests/tools/askBrowserstack.test.ts @@ -11,8 +11,10 @@ import { } from "../../src/tools/ask-browserstack/config.js"; import { AUTH_REJECTED_DETAIL, + AUTH_SCOPE_REFUSED_DETAIL, AUTH_UNREACHABLE_DETAIL, CENTRAL_SCOPE, + refusalIsAboutScope, REFRESH_SKEW_MS, mintCentralToken, mintForm, @@ -668,13 +670,33 @@ describe("minting a central JWT", () => { grant_type: "client_credentials", username: "ing_Xx", access_key: "SECRET", - scope: "oauth_user_profile central_ai_s2s", + scope: "oauth_user_profile ai_agent_notify", expires_in: "3600", }); - // Load-bearing in both directions: `central_ai_s2s` is what Atlas matches on, and the - // endpoint will not issue it to a username+access_key unless `oauth_user_profile` is - // paired with it. - expect(CENTRAL_SCOPE.split(" ")).toEqual(["oauth_user_profile", "central_ai_s2s"]); + // Exact string, both members, in order: `ai_agent_notify` is what Atlas matches on, and + // `oauth_user_profile` is what makes the pair obtainable through this flow at all. + expect(CENTRAL_SCOPE).toBe("oauth_user_profile ai_agent_notify"); + expect(CENTRAL_SCOPE.split(" ")).toEqual(["oauth_user_profile", "ai_agent_notify"]); + }); + + it("never falls back to another scope, on any path", async () => { + // A silent downgrade to a different authorization is the kind of thing nobody notices + // until it matters. Every refusal shape must fail, once, with the scope we chose. + for (const response of [ + { status: 400, body: { error: "invalid_scope" } }, + { status: 401, body: { error: "invalid_client" } }, + { status: 403, body: { error: "unauthorized_client" } }, + { status: 500, body: null }, + ]) { + resetTokenCache(); + const { seen, transport } = recording(response); + await mintCentralToken(URL_, CREDS, transport, 0).catch(() => undefined); + expect(seen).toHaveLength(1); + expect(seen[0].form.scope).toBe("oauth_user_profile ai_agent_notify"); + } + // and nothing anywhere in the module names the scope it replaced + expect(JSON.stringify([AUTH_SCOPE_REFUSED_DETAIL(400), AUTH_REJECTED_DETAIL(401)])) + .not.toContain("central_ai_s2s"); }); it("returns the token the endpoint issued", async () => { @@ -730,7 +752,60 @@ describe("minting a central JWT", () => { expect(seen).toHaveLength(2); }); - it("surfaces ONLY the status when the endpoint refuses, never the body", async () => { + it("reports a refused SCOPE as a provisioning problem, naming the scope", async () => { + // The likely outcome: `ai_agent_notify` is documented as client_id/secret auth and is + // not in USERNAME_ACCESS_KEY_ONLY_SCOPES. Sending someone to check their password would + // be sending them to the wrong place entirely. + const transport = async () => ({ + status: 400, + body: { + error: "invalid_scope", + error_description: "scope ai_agent_notify only valid for: user_management, access_key SECRET", + }, + }); + const message = await mintCentralToken(URL_, CREDS, transport, 0).catch((e) => e.message); + expect(message).toContain("oauth_user_profile ai_agent_notify"); + expect(message).toMatch(/provisioning problem/); + expect(message).toMatch(/YOUR CREDENTIALS ARE NOT THE PROBLEM/); + expect(message).toMatch(/NOT quietly retry with a weaker scope/); + expect(message).not.toMatch(/Check BROWSERSTACK_USERNAME/); + // The body still never crosses, even while being read to classify. + expect(message).not.toContain("SECRET"); + expect(message).not.toContain("error_description"); + expect(message).not.toContain("only valid for"); + }); + + it("classifies a refusal by the OAuth2 code, falling back to the status", () => { + // The `error` code is a fixed spec token and cannot carry a credential; + // `error_description` is free text and can, so only the code is ever consulted. + expect(refusalIsAboutScope(400, { error: "invalid_scope" })).toBe(true); + expect(refusalIsAboutScope(403, { error: "unauthorized_client" })).toBe(true); + expect(refusalIsAboutScope(400, { error: "invalid_request" })).toBe(true); + expect(refusalIsAboutScope(401, { error: "invalid_client" })).toBe(false); + expect(refusalIsAboutScope(400, { error: "invalid_grant" })).toBe(false); + // No usable code: 400 is a bad request (for us, the scope), 401/403 a bad caller. + expect(refusalIsAboutScope(400, null)).toBe(true); + expect(refusalIsAboutScope(400, { error: 42 })).toBe(true); + expect(refusalIsAboutScope(400, { error: "something_new" })).toBe(true); + expect(refusalIsAboutScope(401, {})).toBe(false); + expect(refusalIsAboutScope(500, { error: "server_error" })).toBe(false); + }); + + it("keeps a scope refusal and a credential refusal as two different problems", async () => { + const scope = await mintCentralToken( + URL_, CREDS, async () => ({ status: 400, body: { error: "invalid_scope" } }), 0, + ).catch((e) => e.message); + resetTokenCache(); + const credential = await mintCentralToken( + URL_, CREDS, async () => ({ status: 401, body: { error: "invalid_client" } }), 0, + ).catch((e) => e.message); + expect(scope).not.toBe(credential); + expect(scope).toMatch(/provisioning/); + expect(credential).toMatch(/credentials were rejected/); + expect(credential).not.toMatch(/provisioning/); + }); + + it("surfaces ONLY the status when the CREDENTIAL is refused, never the body", async () => { // The real endpoint's error body echoes the credential straight back. const transport = async () => ({ status: 401, @@ -782,18 +857,24 @@ describe("minting a central JWT", () => { expect(seen).toHaveLength(0); }); - it("keeps the three auth failures readable as three different problems", () => { + it("keeps the four auth failures readable as four different problems", () => { + const scopeRefused = AUTH_SCOPE_REFUSED_DETAIL(400); const rejected = AUTH_REJECTED_DETAIL(401); const unreachable = AUTH_UNREACHABLE_DETAIL; const refusedByAtlas = UNAUTHENTICATED_DETAIL; - expect(new Set([rejected, unreachable, refusedByAtlas]).size).toBe(3); - // "your credentials are wrong" vs "auth is down" vs "the server is misconfigured" + const all = [scopeRefused, rejected, unreachable, refusedByAtlas]; + expect(new Set(all).size).toBe(4); + // provisioning vs "your credentials are wrong" vs "auth is down" vs "server misconfigured" + expect(scopeRefused).toMatch(/provisioning problem/); expect(rejected).toMatch(/credentials were rejected/); expect(unreachable).toMatch(/Could not reach BrowserStack auth/); expect(refusedByAtlas).toMatch(/SUCCEEDED/); - expect(refusedByAtlas).toMatch(/YOUR CREDENTIALS ARE NOT THE PROBLEM/); + // The two that are NOT the user's credentials say so in as many words. + for (const message of [scopeRefused, refusedByAtlas]) { + expect(message).toMatch(/YOUR CREDENTIALS ARE NOT THE PROBLEM/); + } // None of them is a permission denial. - for (const message of [rejected, unreachable, refusedByAtlas]) { + for (const message of all) { expect(message).toMatch(/NOTHING REACHED THE AGENT|never reached the agent/); } }); diff --git a/tests/tools/askBrowserstackE2E.test.ts b/tests/tools/askBrowserstackE2E.test.ts index 41b80ff..a044e77 100644 --- a/tests/tools/askBrowserstackE2E.test.ts +++ b/tests/tools/askBrowserstackE2E.test.ts @@ -35,6 +35,7 @@ function atlas(options: { payload?: (decisions: any[]) => unknown; throws?: boolean; authStatus?: number; + authError?: string; }) { const calls: AtlasCall[] = []; const decisions: any[] = []; @@ -47,7 +48,11 @@ function atlas(options: { status: options.authStatus ?? 200, headers: { get: () => "application/json" }, json: async () => (options.authStatus && options.authStatus !== 200 - ? { error: "invalid_client", error_description: "access_key SECRET is invalid" } + ? { + error: options.authError ?? "invalid_client", + error_description: + "scope ai_agent_notify only valid for: user_management, access_key SECRET", + } : { access_token: MINTED, expires_in: 3600, token_type: "Bearer" }), }; } @@ -645,9 +650,9 @@ describe("askBrowserstackAI, end to end through the server factory", () => { grant_type: "client_credentials", username: "ing_Xx", access_key: "SECRET", - // BOTH parts. `central_ai_s2s` alone is refused by the endpoint; without it Atlas - // refuses the token. - scope: "oauth_user_profile central_ai_s2s", + // BOTH parts, exact string. `ai_agent_notify` is what Atlas matches on; + // `oauth_user_profile` is what makes the pair obtainable through this flow. + scope: "oauth_user_profile ai_agent_notify", expires_in: "3600", }); }); @@ -677,6 +682,28 @@ describe("askBrowserstackAI, end to end through the server factory", () => { expect(result.content[0].text).not.toContain(MINTED); }); + it("reports a refused scope as provisioning, not as a bad password", async () => { + const server = await buildServer(); + const elicit = fakeClient(server.getInstance(), { elicitation: {} }, []); + const stub = atlas({ authStatus: 400, authError: "invalid_scope" }); + + const { result, payload } = await call(server.getTools()); + + expect(payload.error).toContain("oauth_user_profile ai_agent_notify"); + expect(payload.error).toMatch(/provisioning problem/); + expect(payload.error).not.toMatch(/Check BROWSERSTACK_USERNAME/); + // Only one sign-in attempt, and no retry with a different scope. + expect(stub.mints).toHaveLength(1); + expect(stub.mints[0].scope).toBe("oauth_user_profile ai_agent_notify"); + // The body is read to classify but never surfaced. + expect(result.content[0].text).not.toContain("SECRET"); + expect(result.content[0].text).not.toContain("only valid for"); + expect(stub.calls).toHaveLength(0); + expect(elicit).not.toHaveBeenCalled(); + expect(payload.permission_relay.reason).toBe("not_reached"); + expect(payload.applied_before_stop).toBeNull(); + }); + it("says the credentials were rejected, and NEVER echoes the auth body back", async () => { const server = await buildServer(); const elicit = fakeClient(server.getInstance(), { elicitation: {} }, []); From 8067a0df33d628355129d51d36eb3aeaa69adcd0 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Tue, 25 Aug 2026 10:38:58 +0530 Subject: [PATCH 14/31] Let the approve button approve MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A user approved a prompt against preprod and was told they had refused it. The schema was at fault, not the client: confirm was a required boolean defaulting to false, so a client renders one unchecked checkbox, pressing approve submits accept with confirm false, and the mapping turned that into deny/declined. The approve path was unreachable unless the user also toggled a field they had no reason to think was load-bearing. "form" is the only elicitation mode the SDK has, so there was no confirm mode to move to. For a yes/no approval the action already is the answer — accept, decline and cancel carry exactly the three outcomes needed — and a boolean inside the form duplicated that signal while contradicting it. So confirm becomes optional, with no default, and an accept that carries no confirm field is an approval. This looks like a loosening and is not. The guard against an unattended run was never that boolean; it is that a headless client returns cancel, which was measured and is unchanged, and an accept means the protocol itself says a human accepted. What the old shape actually bought was a false denial, indistinguishable in the result from a human saying no — the same confusion that two earlier fixes existed to remove, arriving this time through the front door. The comment that claimed the protection is replaced by that reasoning rather than left standing over code that no longer relies on it. An explicit false is still a refusal, because a client that does render the checkbox and a user who unticks it have said no. Only absence is consent, and nothing is coerced: a string "true" or a 1 is a malformed answer, not an approval. The emitted schema is asserted to carry neither a required array nor a default, so this cannot come back quietly. --- src/tools/ask-browserstack/register.ts | 20 ++++++++--- src/tools/ask-browserstack/relay.ts | 36 ++++++++++++-------- tests/tools/askBrowserstack.test.ts | 25 ++++++++++---- tests/tools/askBrowserstackE2E.test.ts | 46 ++++++++++++++++++++++++-- 4 files changed, 102 insertions(+), 25 deletions(-) diff --git a/src/tools/ask-browserstack/register.ts b/src/tools/ask-browserstack/register.ts index 50deba5..562bbfd 100644 --- a/src/tools/ask-browserstack/register.ts +++ b/src/tools/ask-browserstack/register.ts @@ -150,12 +150,24 @@ async function relayOneAsk( type: "boolean", title: CONFIRM_TITLE, description: CONFIRM_DESCRIPTION, - // Defaulting to false so that a client which submits the form untouched - // refuses rather than approves. - default: false, }, }, - required: ["confirm"], + // NEITHER `required` NOR `default: false`, and this is not the loosening it looks + // like. Both were here so that a form submitted untouched would refuse rather than + // approve — but "form" is the only elicitation mode the SDK has, so a client + // renders this as an unchecked checkbox, and pressing APPROVE sent + // `accept` + `confirm: false`. That made the approve path literally unreachable: + // a human who approved got back "refused: a human said no". + // + // The guard against an unattended run was never this boolean. It is that a + // headless client returns `cancel`, which is measured (HANDOFF.md) and unchanged, + // and `accept` means the protocol itself says a human accepted. The cost of the + // old shape was not caution, it was a FALSE DENIAL — indistinguishable in the + // result from a real refusal, which is the exact confusion D3 and N1 existed to + // remove. + // + // The field stays so a client that does render it still offers an explicit tick, + // and an explicit untick is still honoured as a refusal. Only ABSENCE is consent. }, }, // The inner rung of CONTRACT §4's ladder, strictly shorter than Atlas's 300s gate. diff --git a/src/tools/ask-browserstack/relay.ts b/src/tools/ask-browserstack/relay.ts index 8143cfd..ffe9dea 100644 --- a/src/tools/ask-browserstack/relay.ts +++ b/src/tools/ask-browserstack/relay.ts @@ -168,28 +168,38 @@ export function elicitationMessage(product: string, description: string): string } /** - * CONTRACT §7, exactly. + * CONTRACT §7. * - * | accept + confirm: true | allow | "" | - * | accept + confirm: false | deny | declined | - * | decline | deny | declined | - * | cancel | deny | cancelled | + * | accept, `confirm` absent | allow | "" | + * | accept + confirm: true | allow | "" | + * | accept + confirm: false | deny | declined | + * | accept + anything else | deny | declined | + * | decline | deny | declined | + * | cancel | deny | cancelled | * - * `cancel` IS THE LOAD-BEARING ROW. A headless Claude Code with no human at a terminal - * returns `cancel` — measured, not assumed — so treating it as anything but a deny would - * let an unattended run self-approve, which is the one property that makes this feature - * safe to ship. It is also why the caller never retries an elicitation: a second ask cannot - * conjure a human, it can only wear one down. + * THE ACTION IS THE ANSWER. `accept`/`decline`/`cancel` already carry the three outcomes a + * yes/no approval needs; a boolean inside the form duplicates that signal, and while it was + * mandatory with `default: false` it CONTRADICTED it — a client renders one unchecked + * checkbox, approve submits `accept` + `confirm: false`, and a human who approved was told + * "refused: a human said no". So absence of the field is consent now. * - * `confirm` is compared to the boolean `true` and nothing else. A string "true", a 1, or a - * missing field is not consent. + * `cancel` IS STILL THE LOAD-BEARING ROW, and it is the whole of the fail-closed guarantee. + * A headless Claude Code with no human at a terminal returns `cancel` — measured, not + * assumed — so an unattended run still cannot self-approve. That never depended on the + * boolean. It is also why an elicitation is never retried: a second ask cannot conjure a + * human, it can only wear one down. + * + * An EXPLICIT `false` is still a refusal, because a client that does render the checkbox and + * a user who unticks it have said no, and that must be honoured. Nothing is coerced: a + * string "true", a 1 or a null is not consent either — only `true`, or nothing at all. */ export function decide(result: ElicitResult): { decision: Decision; reason: DecisionReason; } { if (result.action === "accept") { - return result.content?.confirm === true + const confirm = result.content?.confirm; + return confirm === undefined || confirm === true ? { decision: "allow", reason: "" } : { decision: "deny", reason: "declined" }; } diff --git a/tests/tools/askBrowserstack.test.ts b/tests/tools/askBrowserstack.test.ts index ad8b7f4..089bab9 100644 --- a/tests/tools/askBrowserstack.test.ts +++ b/tests/tools/askBrowserstack.test.ts @@ -68,12 +68,23 @@ async function post( } describe("decide — CONTRACT §7, and nothing but", () => { - it("allows only an explicit accept AND confirm: true", () => { + it("allows an accept whose form carried no confirm field at all", () => { + // THE LIVE BUG. `confirm` used to be required with `default: false`, so a client + // rendered one unchecked checkbox and pressing APPROVE sent accept + confirm: false — + // and a human who approved was told "refused: a human said no". The approve path was + // unreachable. Absence of the field is now consent; `accept` already IS the answer. + expect(decide({ action: "accept" })) + .toEqual({ decision: "allow", reason: "" }); + expect(decide({ action: "accept", content: {} })) + .toEqual({ decision: "allow", reason: "" }); + }); + + it("allows an explicit accept AND confirm: true", () => { expect(decide({ action: "accept", content: { confirm: true } })) .toEqual({ decision: "allow", reason: "" }); }); - it("denies accept + confirm: false as the human saying no", () => { + it("still honours an explicit untick as the human saying no", () => { expect(decide({ action: "accept", content: { confirm: false } })) .toEqual({ decision: "deny", reason: "declined" }); }); @@ -91,10 +102,12 @@ describe("decide — CONTRACT §7, and nothing but", () => { }); it("does not accept a truthy stand-in for consent", () => { - // A string "true" or a 1 is a client bug, not an approval. - expect(decide({ action: "accept", content: { confirm: "true" } }).decision).toBe("deny"); - expect(decide({ action: "accept", content: {} }).decision).toBe("deny"); - expect(decide({ action: "accept" }).decision).toBe("deny"); + // Only the boolean `true`, or nothing at all. A string "true" or a 1 is a client bug, + // and coercing it would be inventing consent from a malformed answer. + for (const confirm of ["true", 1, "yes", null]) { + expect(decide({ action: "accept", content: { confirm } } as never)) + .toEqual({ decision: "deny", reason: "declined" }); + } }); it("treats an action it does not recognise as no answer at all", () => { diff --git a/tests/tools/askBrowserstackE2E.test.ts b/tests/tools/askBrowserstackE2E.test.ts index a044e77..34aeff9 100644 --- a/tests/tools/askBrowserstackE2E.test.ts +++ b/tests/tools/askBrowserstackE2E.test.ts @@ -200,8 +200,16 @@ describe("askBrowserstackAI, end to end through the server factory", () => { "BrowserStack AI (Test Management) needs your approval to continue:\n\n" + "Create folder \"Regression\".", ); - expect(request.requestedSchema.properties.confirm.type).toBe("boolean"); - expect(request.requestedSchema.required).toEqual(["confirm"]); + // NEITHER of these may come back. A required boolean defaulting to false made the + // approve button unable to approve: the client rendered an unchecked box and + // submitted accept + confirm: false. + expect(request.requestedSchema.required).toBeUndefined(); + expect(request.requestedSchema.properties.confirm).toEqual({ + type: "boolean", + title: "Approve this change", + description: expect.stringContaining("Yes, make this change"), + }); + expect("default" in request.requestedSchema.properties.confirm).toBe(false); // The inner rung of the timeout ladder, shorter than Atlas's 300s gate. expect((elicit.mock.calls[0][1] as any).timeout).toBe(270_000); @@ -226,6 +234,40 @@ describe("askBrowserstackAI, end to end through the server factory", () => { expect(payload.permission_relay.used).toBe(true); }); + it("APPROVES when the client accepts without sending a confirm field", async () => { + // Exactly what a real client sends for a schema with no required fields, and the + // shape the user hit on preprod when they pressed approve and were told they had + // refused. + const server = await buildServer(); + const elicit = fakeClient(server.getInstance(), { elicitation: {} }, [ + { action: "accept" }, + ]); + const stub = atlas({ + asks: [{ perm_id: PERM_A, description: 'Creating the root folder "askrelay-smoke-1".' }], + payload: () => ({ + ok: true, status: "ok", answer: "Created it.", steps: [], + approvals: [{ + description: 'Creating the root folder "askrelay-smoke-1".', + decision: "allow", reason: "", applied: true, + }], + applied_before_stop: false, + permission_relay: { used: true, reason: "" }, + }), + }); + + const { payload } = await call(server.getTools()); + + expect(elicit).toHaveBeenCalledTimes(1); + // On the wire to Atlas: an allow, not a denial. + expect(stub.decisions[0].body) + .toEqual({ perm_id: PERM_A, decision: "allow", reason: "" }); + // And the two trails agree that it was approved. + expect(payload.approvals[0]).toMatchObject({ decision: "allow", applied: true }); + expect(payload.elicitations[0]).toMatchObject({ decision: "allow", reason: "" }); + expect(payload.approvals[0].outcome).toBe("approved, and the change went through"); + expect(payload.status).toBe("ok"); + }); + it("denies when the human confirms false, and never asks a second time", async () => { const server = await buildServer(); const elicit = fakeClient(server.getInstance(), { elicitation: {} }, [ From 9e076742feb9a68bab1430525d21ea6557127957 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Tue, 25 Aug 2026 10:45:27 +0530 Subject: [PATCH 15/31] Ask nothing in the form, and let the action be the answer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A boolean inside a form whose accept action already means approval can only agree with that action or contradict it, and when it contradicts we cannot tell which the human meant. Accept with confirm false is either "I approved and never saw the checkbox" — the false denial a user hit on preprod — or "I unticked it deliberately". Guessing either way is wrong for the other case, and it could not be settled by inspecting what the client sends, because that binary is compiled and its strings too fragmented to read. So the question is removed rather than answered: requestedSchema now asks for nothing, and decline already offers an unambiguous refusal in the same dialog. Fail-closed is untouched and never rested on the boolean. A headless client with nobody at the terminal returns cancel, which is measured and is a deny, and that is the whole of what stops an unattended run approving itself. A volunteered confirm false is still honoured, documented as defensive only, since no client can be expected to send a field nobody asked for. The answer's shape is now logged once per ask — the action, whether content came back, and whether confirm was among it. Only a fixed enum and a boolean, so no description, credential or typed text can ride along, which is asserted rather than assumed. Next time this misbehaves the client's answer can be read instead of inferred. --- src/tools/ask-browserstack/register.ts | 51 +++++++++--------- src/tools/ask-browserstack/relay.ts | 71 +++++++++++++++++--------- tests/tools/askBrowserstack.test.ts | 38 +++++++++++--- tests/tools/askBrowserstackE2E.test.ts | 46 +++++++++++++---- 4 files changed, 138 insertions(+), 68 deletions(-) diff --git a/src/tools/ask-browserstack/register.ts b/src/tools/ask-browserstack/register.ts index 562bbfd..48e077f 100644 --- a/src/tools/ask-browserstack/register.ts +++ b/src/tools/ask-browserstack/register.ts @@ -53,7 +53,13 @@ import { CallbackListener, startCallbackListener, } from "./callback.js"; -import { buildResult, decide, elicitationMessage, errorResult } from "./relay.js"; +import { + buildResult, + decide, + elicitationMessage, + elicitationShape, + errorResult, +} from "./relay.js"; import { AgentRequest, ApprovalRecord, @@ -95,10 +101,6 @@ const DESCRIPTION = "the run is read-only and everything it wanted to change comes back in `needs_approval` " + "instead. One task per call."; -const CONFIRM_TITLE = "Approve this change"; -const CONFIRM_DESCRIPTION = - "Yes, make this change. Anything else — including dismissing this prompt — refuses it."; - /** * `isError` marks a call that FAILED, not one that was refused. * @@ -144,30 +146,18 @@ async function relayOneAsk( // withheld-placeholder sentence, which reads correctly after the prefix. message: elicitationMessage(ask.product, ask.description), requestedSchema: { - type: "object", - properties: { - confirm: { - type: "boolean", - title: CONFIRM_TITLE, - description: CONFIRM_DESCRIPTION, - }, - }, - // NEITHER `required` NOR `default: false`, and this is not the loosening it looks - // like. Both were here so that a form submitted untouched would refuse rather than - // approve — but "form" is the only elicitation mode the SDK has, so a client - // renders this as an unchecked checkbox, and pressing APPROVE sent - // `accept` + `confirm: false`. That made the approve path literally unreachable: - // a human who approved got back "refused: a human said no". + // NOTHING IS REQUESTED. The action IS the answer: `accept` already means the human + // approved, and `decline` already gives them an unambiguous refusal in the same + // dialog. A `confirm` boolean used to live here and produced a FALSE DENIAL — a + // user approved on preprod and was told they had refused, because a client renders + // a form field and submits its unset value. We cannot distinguish that from a + // deliberate untick, so the field is gone rather than guessed at. // - // The guard against an unattended run was never this boolean. It is that a - // headless client returns `cancel`, which is measured (HANDOFF.md) and unchanged, - // and `accept` means the protocol itself says a human accepted. The cost of the - // old shape was not caution, it was a FALSE DENIAL — indistinguishable in the - // result from a real refusal, which is the exact confusion D3 and N1 existed to - // remove. - // - // The field stays so a client that does render it still offers an explicit tick, - // and an explicit untick is still honoured as a refusal. Only ABSENCE is consent. + // Fail-closed is untouched by this and never rested on the boolean: a headless + // client with nobody at the terminal returns `cancel` (measured, HANDOFF.md), and + // `cancel` is a deny. That is what stops an unattended run self-approving. + type: "object", + properties: {}, }, }, // The inner rung of CONTRACT §4's ladder, strictly shorter than Atlas's 300s gate. @@ -193,6 +183,11 @@ async function relayOneAsk( throw error; } + // The SHAPE of the answer only — a fixed action enum and a boolean, never the description + // or anything a user typed. Logged so that what a client actually submits can be read next + // time rather than inferred from a compiled binary. + logger.info("askBrowserstackAI: elicitation answered %s", elicitationShape(answer)); + const { decision, reason } = decide(answer); approvals.push({ description: ask.description, decision, reason }); return { perm_id: ask.perm_id, decision, reason }; diff --git a/src/tools/ask-browserstack/relay.ts b/src/tools/ask-browserstack/relay.ts index ffe9dea..20fbb9c 100644 --- a/src/tools/ask-browserstack/relay.ts +++ b/src/tools/ask-browserstack/relay.ts @@ -168,40 +168,38 @@ export function elicitationMessage(product: string, description: string): string } /** - * CONTRACT §7. + * CONTRACT §7. THE ACTION IS THE WHOLE ANSWER. * - * | accept, `confirm` absent | allow | "" | - * | accept + confirm: true | allow | "" | - * | accept + confirm: false | deny | declined | - * | accept + anything else | deny | declined | - * | decline | deny | declined | - * | cancel | deny | cancelled | + * | accept | allow | "" | + * | decline | deny | declined | + * | cancel | deny | cancelled | * - * THE ACTION IS THE ANSWER. `accept`/`decline`/`cancel` already carry the three outcomes a - * yes/no approval needs; a boolean inside the form duplicates that signal, and while it was - * mandatory with `default: false` it CONTRADICTED it — a client renders one unchecked - * checkbox, approve submits `accept` + `confirm: false`, and a human who approved was told - * "refused: a human said no". So absence of the field is consent now. + * Nothing is requested in the form any more, so nothing can contradict the action. There used + * to be a `confirm` boolean, and it had to go: with an `accept` action that ALREADY means the + * human approved, `accept` + `confirm: false` is genuinely ambiguous between "I approved, and + * a checkbox I never saw defaulted to false" and "I unticked it deliberately". The first is a + * FALSE DENIAL — indistinguishable in the result from a human refusing, which is the exact + * confusion D3 and N1 existed to remove — and a user hit it live. We cannot tell the two + * apart, and guessing either way is wrong for the other. `decline` already gives an + * unambiguous refusal in the same dialog, so the boolean bought nothing. * - * `cancel` IS STILL THE LOAD-BEARING ROW, and it is the whole of the fail-closed guarantee. - * A headless Claude Code with no human at a terminal returns `cancel` — measured, not - * assumed — so an unattended run still cannot self-approve. That never depended on the - * boolean. It is also why an elicitation is never retried: a second ask cannot conjure a - * human, it can only wear one down. - * - * An EXPLICIT `false` is still a refusal, because a client that does render the checkbox and - * a user who unticks it have said no, and that must be honoured. Nothing is coerced: a - * string "true", a 1 or a null is not consent either — only `true`, or nothing at all. + * FAIL-CLOSED IS UNCHANGED, and the boolean was never what provided it. A headless client with + * no human at a terminal returns `cancel` — measured, not assumed (HANDOFF.md) — and `cancel` + * is a deny. That is why an unattended run still cannot self-approve. It is also why an + * elicitation is never retried: a second ask cannot conjure a human, only wear one down. */ export function decide(result: ElicitResult): { decision: Decision; reason: DecisionReason; } { if (result.action === "accept") { - const confirm = result.content?.confirm; - return confirm === undefined || confirm === true - ? { decision: "allow", reason: "" } - : { decision: "deny", reason: "declined" }; + // DEFENSIVE ONLY. We no longer request this field, so no client can be expected to send + // it — but one that volunteers an explicit `false` has said something, and honouring it + // costs nothing. Absence, which is the normal case, is consent. + if (result.content?.confirm === false) { + return { decision: "deny", reason: "declined" }; + } + return { decision: "allow", reason: "" }; } if (result.action === "decline") return { decision: "deny", reason: "declined" }; // `cancel`, and anything a future client sends that we do not recognise: no explicit @@ -209,6 +207,29 @@ export function decide(result: ElicitResult): { return { decision: "deny", reason: "cancelled" }; } +/** + * A one-line description of the SHAPE of what a client answered with — never its content. + * + * Which client sends what is currently guesswork: the elicitation bug in task 9 had to be + * fixed without being able to confirm what Claude Code actually submits, because its binary + * is compiled and its strings too fragmented to read. This line means the next person can + * look it up instead of inferring it. + * + * `action` and `confirm` are a fixed enum and a boolean; neither can carry a description, a + * credential or anything else a user typed. + */ +export function elicitationShape(result: ElicitResult): string { + const content = result.content; + const confirm = content?.confirm; + const seen = + confirm === undefined + ? "absent" + : typeof confirm === "boolean" + ? String(confirm) + : "non-boolean"; + return `action=${result.action} content=${content ? "present" : "absent"} confirm=${seen}`; +} + /** * Read Atlas's `applied_before_stop`. NEVER DERIVE IT. * diff --git a/tests/tools/askBrowserstack.test.ts b/tests/tools/askBrowserstack.test.ts index 089bab9..47b1b57 100644 --- a/tests/tools/askBrowserstack.test.ts +++ b/tests/tools/askBrowserstack.test.ts @@ -25,6 +25,7 @@ import { approvalOutcome, buildResult, decide, + elicitationShape, errorResult, deriveStatus, elicitationMessage, @@ -79,7 +80,7 @@ describe("decide — CONTRACT §7, and nothing but", () => { .toEqual({ decision: "allow", reason: "" }); }); - it("allows an explicit accept AND confirm: true", () => { + it("allows an accept that volunteers confirm: true", () => { expect(decide({ action: "accept", content: { confirm: true } })) .toEqual({ decision: "allow", reason: "" }); }); @@ -101,13 +102,38 @@ describe("decide — CONTRACT §7, and nothing but", () => { .toEqual({ decision: "deny", reason: "cancelled" }); }); - it("does not accept a truthy stand-in for consent", () => { - // Only the boolean `true`, or nothing at all. A string "true" or a 1 is a client bug, - // and coercing it would be inventing consent from a malformed answer. - for (const confirm of ["true", 1, "yes", null]) { + it("ignores whatever a client volunteers, except an explicit false", () => { + // Nothing is requested any more, so the action decides. A volunteered `false` is still + // honoured as defensive belt, but no other value can override an accept. + for (const confirm of ["true", 1, "yes", null, undefined]) { expect(decide({ action: "accept", content: { confirm } } as never)) - .toEqual({ decision: "deny", reason: "declined" }); + .toEqual({ decision: "allow", reason: "" }); } + expect(decide({ action: "accept", content: { confirm: false } })) + .toEqual({ decision: "deny", reason: "declined" }); + }); + + it("describes the shape a client answered with, and nothing a human wrote", () => { + expect(elicitationShape({ action: "accept" })) + .toBe("action=accept content=absent confirm=absent"); + expect(elicitationShape({ action: "accept", content: {} })) + .toBe("action=accept content=present confirm=absent"); + expect(elicitationShape({ action: "accept", content: { confirm: false } })) + .toBe("action=accept content=present confirm=false"); + expect(elicitationShape({ action: "cancel" })) + .toBe("action=cancel content=absent confirm=absent"); + expect(elicitationShape({ action: "accept", content: { confirm: "yes" } } as never)) + .toBe("action=accept content=present confirm=non-boolean"); + + // It can only ever contain a fixed action enum and a boolean — never a description, a + // credential, or anything a user typed into a form. + const line = elicitationShape({ + action: "accept", + content: { confirm: "SECRET", notes: 'Creating the folder "askrelay-smoke-1"' }, + } as never); + expect(line).not.toContain("SECRET"); + expect(line).not.toContain("askrelay-smoke-1"); + expect(line).toBe("action=accept content=present confirm=non-boolean"); }); it("treats an action it does not recognise as no answer at all", () => { diff --git a/tests/tools/askBrowserstackE2E.test.ts b/tests/tools/askBrowserstackE2E.test.ts index 34aeff9..590161d 100644 --- a/tests/tools/askBrowserstackE2E.test.ts +++ b/tests/tools/askBrowserstackE2E.test.ts @@ -200,16 +200,13 @@ describe("askBrowserstackAI, end to end through the server factory", () => { "BrowserStack AI (Test Management) needs your approval to continue:\n\n" + "Create folder \"Regression\".", ); - // NEITHER of these may come back. A required boolean defaulting to false made the - // approve button unable to approve: the client rendered an unchecked box and - // submitted accept + confirm: false. + // NOTHING IS REQUESTED. A `confirm` boolean here made the approve button unable to + // approve — the client rendered a form field and submitted its unset value, so an + // approval came back as a refusal. The action is the whole answer now, and this + // assertion is what stops the field creeping back. + expect(request.requestedSchema).toEqual({ type: "object", properties: {} }); expect(request.requestedSchema.required).toBeUndefined(); - expect(request.requestedSchema.properties.confirm).toEqual({ - type: "boolean", - title: "Approve this change", - description: expect.stringContaining("Yes, make this change"), - }); - expect("default" in request.requestedSchema.properties.confirm).toBe(false); + expect(Object.keys(request.requestedSchema.properties)).toEqual([]); // The inner rung of the timeout ladder, shorter than Atlas's 300s gate. expect((elicit.mock.calls[0][1] as any).timeout).toBe(270_000); @@ -268,6 +265,37 @@ describe("askBrowserstackAI, end to end through the server factory", () => { expect(payload.status).toBe("ok"); }); + it("logs the SHAPE of the answer, never the description or a credential", async () => { + // The module's default export is a Proxy, so it is swapped wholesale rather than spied. + const { setLogger } = await import("../../src/logger.js"); + const lines: string[] = []; + const capture = (...args: unknown[]) => lines.push(args.map(String).join(" ")); + const previous = (await import("pino")).pino({ level: "silent" }); + setLogger({ info: capture, warn: capture, error: capture, debug: capture, flush: () => {} }); + + const server = await buildServer(); + fakeClient(server.getInstance(), { elicitation: {} }, [{ action: "accept" }]); + atlas({ + asks: [{ perm_id: PERM_A, description: 'Creating the root folder "askrelay-smoke-1".' }], + }); + + try { + await call(server.getTools()); + } finally { + setLogger(previous); + } + + const answered = lines.filter((line) => line.includes("elicitation answered")); + expect(answered).toHaveLength(1); // once per ask, never per retry + expect(answered[0]).toContain("action=accept"); + expect(answered[0]).toContain("content=absent"); + // The whole point: readable next time, and safe to leave switched on. + const everything = lines.join("\n"); + expect(everything).not.toContain("askrelay-smoke-1"); + expect(everything).not.toContain("SECRET"); + expect(everything).not.toContain(MINTED); + }); + it("denies when the human confirms false, and never asks a second time", async () => { const server = await buildServer(); const elicit = fakeClient(server.getInstance(), { elicitation: {} }, [ From 20b2dcfddba081d0e58a7e35bbefe079babb9123 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Tue, 25 Aug 2026 11:33:16 +0530 Subject: [PATCH 16/31] Let TM region discovery be pointed at a non-production environment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A test harness is running the documented sample queries to measure which tool Claude picks, but the credentials available are preprod-only and the classic tools probe production, so they 401. That does not corrupt the selection itself, which happens before any call, but it corrupts what the selection means: a 401 can send the model off to retry with a different tool, so "chose ours because it fit" and "chose ours because the other one was broken" become the same observation. So the probe list gets an env override, named and parsed after the capability registry's, and it REPLACES the built-in list rather than extending it — appending would leave the production hosts probed first and the 401s would come straight back. With the variable unset the list is exactly the three production regions in exactly the order they were already in, which is asserted rather than assumed, because this is a harness affordance and must not be able to change what ships. An override that parses to nothing falls back to the built-in list rather than leaving an empty probe loop, which would surface as "unable to connect" with no detail. It warns when it does: quietly talking to production when someone asked for preprod is the failure this exists to prevent, so it must be visible in the log rather than inferred from a 401 much later. The module-level cache is now keyed on the list it was discovered under. Skipping it entirely under an override would have worked too, but keying it also covers the reverse — a value minted against preprod being handed to a run that has since gone back to production — where the override would appear to work while silently returning the wrong environment's host. Accessibility and observability are deliberately not covered. Their hosts are hardcoded inline at nine call sites across five files with no host-resolution module to hook, so the same shape does not drop in; giving them one means rewiring egress across the codebase for a test affordance, which is not a trade worth making here. --- src/lib/tm-base-url.ts | 70 ++++++++++++++++- tests/lib/tm-base-url.test.ts | 137 +++++++++++++++++++++++++++++++++- 2 files changed, 200 insertions(+), 7 deletions(-) diff --git a/src/lib/tm-base-url.ts b/src/lib/tm-base-url.ts index e92165b..475822a 100644 --- a/src/lib/tm-base-url.ts +++ b/src/lib/tm-base-url.ts @@ -4,27 +4,88 @@ import { BrowserStackConfig } from "./types.js"; import { getBrowserStackAuth } from "./get-auth.js"; import appConfig from "../config.js"; -const TM_BASE_URLS = [ +/** + * The production regions, probed in this order. UNCHANGED, and the default in every + * deployment: the override below exists for test harnesses, not for shipping. + */ +export const TM_BASE_URLS = [ "https://test-management.browserstack.com", "https://test-management-eu.browserstack.com", "https://test-management-in.browserstack.com", ] as const; +/** + * A TEST-HARNESS AFFORDANCE. Point region discovery at a non-production environment. + * + * Named and parsed after `CAPABILITY_REGISTRY_BASE_URLS`, plural because the probe loop takes + * a list. It REPLACES the built-in list rather than extending it — appending would leave the + * production hosts probed first, which is the whole problem it exists to avoid: preprod-only + * credentials 401 against production, and a 401 can send the model off to retry with a + * different tool, so a tool-selection measurement stops meaning what it says. + * + * An override that parses to nothing falls back to the built-in list rather than leaving an + * empty probe loop, which would surface as "unable to connect" with no detail. That fallback + * is a WARNING, not a silent one: quietly using production when someone asked for preprod is + * exactly the failure this is meant to prevent. + */ +export const TM_BASE_URLS_ENV = "BROWSERSTACK_TM_BASE_URLS"; + +export interface ResolvedBaseUrls { + urls: string[]; + /** Where the list came from, so a run pointed at the wrong environment is visible. */ + source: "built-in" | "env" | "built-in (override unusable)"; +} + +export function resolveTMBaseUrls(): ResolvedBaseUrls { + const raw = process.env[TM_BASE_URLS_ENV]; + if (!raw || !raw.trim()) return { urls: [...TM_BASE_URLS], source: "built-in" }; + + const urls = raw + .split(",") + .map((entry) => entry.trim().replace(/\/+$/, "")) + // Anything without a scheme is a typo, not a host: silently probing it would produce a + // confusing connection error rather than naming the real mistake. + .filter((entry) => /^https?:\/\/\S+$/i.test(entry)); + + if (!urls.length) { + return { urls: [...TM_BASE_URLS], source: "built-in (override unusable)" }; + } + return { urls, source: "env" }; +} + let cachedBaseUrl: string | null = null; +/** + * Which list the cached URL was discovered under. + * + * Keyed rather than skipped, so a value minted against production can never be served to a + * run pointed at preprod (or the reverse) — the override would otherwise appear to work while + * silently returning the previous environment's host. + */ +let cachedFor: string | null = null; export async function getTMBaseURL( config: BrowserStackConfig, ): Promise { + const { urls, source } = resolveTMBaseUrls(); + const listKey = urls.join(","); + // Skip the module-level cache in remote (multi-tenant) mode: it is process-shared, // so the first user's region would be served to every subsequent user — breaking // requests for users on a different region's BrowserStack account. - if (!appConfig.REMOTE_MCP && cachedBaseUrl) { + if (!appConfig.REMOTE_MCP && cachedBaseUrl && cachedFor === listKey) { logger.debug(`Using cached TM base URL: ${cachedBaseUrl}`); return cachedBaseUrl; } + if (source === "built-in (override unusable)") { + logger.warn( + `${TM_BASE_URLS_ENV} was set but no entry looked like an http(s) URL; falling back ` + + `to the built-in production list. Requests will go to production.`, + ); + } logger.info( - "No cached TM base URL found, testing available URLs with authentication", + `No cached TM base URL found, testing available URLs with authentication ` + + `(list from ${source}: ${listKey})`, ); const authString = getBrowserStackAuth(config); @@ -34,7 +95,7 @@ export async function getTMBaseURL( const failures: string[] = []; - for (const baseUrl of TM_BASE_URLS) { + for (const baseUrl of urls) { try { const res = await apiClient.get({ url: `${baseUrl}/api/v2/projects/`, @@ -51,6 +112,7 @@ export async function getTMBaseURL( // the cache must stay empty so each user discovers their own region. if (!appConfig.REMOTE_MCP) { cachedBaseUrl = baseUrl; + cachedFor = listKey; } logger.info(`Selected TM base URL: ${baseUrl}`); return baseUrl; diff --git a/tests/lib/tm-base-url.test.ts b/tests/lib/tm-base-url.test.ts index eb8c1a2..4061368 100644 --- a/tests/lib/tm-base-url.test.ts +++ b/tests/lib/tm-base-url.test.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; vi.mock("../../src/lib/apiClient", () => ({ apiClient: { @@ -7,7 +7,7 @@ vi.mock("../../src/lib/apiClient", () => ({ })); vi.mock("../../src/logger", () => ({ - default: { error: vi.fn(), info: vi.fn(), debug: vi.fn() }, + default: { error: vi.fn(), info: vi.fn(), debug: vi.fn(), warn: vi.fn() }, })); // Re-imported per-test by resetting modules so the module-level @@ -21,9 +21,21 @@ async function loadModule(remoteMcp: boolean) { })); const apiClientMod = await import("../../src/lib/apiClient"); const tmMod = await import("../../src/lib/tm-base-url"); - return { apiClient: apiClientMod.apiClient, getTMBaseURL: tmMod.getTMBaseURL }; + return { + apiClient: apiClientMod.apiClient, + getTMBaseURL: tmMod.getTMBaseURL, + resolveTMBaseUrls: tmMod.resolveTMBaseUrls, + TM_BASE_URLS: tmMod.TM_BASE_URLS, + }; } +const BUILT_IN = [ + "https://test-management.browserstack.com", + "https://test-management-eu.browserstack.com", + "https://test-management-in.browserstack.com", +]; +const PREPROD = "https://test-management-preprod.bsstag.com"; + const mockConfig = { "browserstack-username": "u", "browserstack-access-key": "k", @@ -107,3 +119,122 @@ describe("getTMBaseURL — failure details", () => { expect(err.message).toMatch(/HTTP 503/); }); }); + +describe("BROWSERSTACK_TM_BASE_URLS — the test-harness override", () => { + const saved = { ...process.env }; + + beforeEach(() => { + vi.clearAllMocks(); + delete process.env.BROWSERSTACK_TM_BASE_URLS; + }); + + afterEach(() => { + process.env = { ...saved }; + }); + + /** The URLs actually probed, in the order they were probed. */ + function probed(apiClient: any): string[] { + return (apiClient.get as any).mock.calls.map((args: any[]) => args[0].url); + } + + it("the built-in list is exactly the three production regions, in order", async () => { + const { TM_BASE_URLS } = await loadModule(false); + // If this ever changes, it is a production change and not a harness one. + expect([...(TM_BASE_URLS as readonly string[])]).toEqual(BUILT_IN); + }); + + it("unset: probes exactly the built-in list, in order", async () => { + const { apiClient, getTMBaseURL, resolveTMBaseUrls } = await loadModule(false); + expect(resolveTMBaseUrls()).toEqual({ urls: BUILT_IN, source: "built-in" }); + + (apiClient.get as any).mockResolvedValue({ ok: false, status: 401 }); + await getTMBaseURL(mockConfig).catch(() => undefined); + expect(probed(apiClient)).toEqual(BUILT_IN.map((u) => `${u}/api/v2/projects/`)); + }); + + it("one URL: REPLACES the list, so production is never probed first", async () => { + // Appending would leave the prod hosts ahead of it and the 401s would come straight + // back, which is the entire problem this exists to remove. + process.env.BROWSERSTACK_TM_BASE_URLS = PREPROD; + const { apiClient, getTMBaseURL, resolveTMBaseUrls } = await loadModule(false); + expect(resolveTMBaseUrls()).toEqual({ urls: [PREPROD], source: "env" }); + + (apiClient.get as any).mockResolvedValueOnce({ ok: true }); + expect(await getTMBaseURL(mockConfig)).toBe(PREPROD); + expect(probed(apiClient)).toEqual([`${PREPROD}/api/v2/projects/`]); + }); + + it("two comma-separated URLs: both, in the given order", async () => { + process.env.BROWSERSTACK_TM_BASE_URLS = `${PREPROD},https://tm-two.bsstag.com`; + const { apiClient, getTMBaseURL } = await loadModule(false); + (apiClient.get as any) + .mockResolvedValueOnce({ ok: false, status: 401 }) + .mockResolvedValueOnce({ ok: true }); + + expect(await getTMBaseURL(mockConfig)).toBe("https://tm-two.bsstag.com"); + expect(probed(apiClient)).toEqual([ + `${PREPROD}/api/v2/projects/`, + "https://tm-two.bsstag.com/api/v2/projects/", + ]); + }); + + it("trims whitespace, drops empty entries and a trailing slash", async () => { + process.env.BROWSERSTACK_TM_BASE_URLS = ` ${PREPROD}/ , , https://tm-two.bsstag.com ,`; + const { resolveTMBaseUrls } = await loadModule(false); + expect(resolveTMBaseUrls()).toEqual({ + urls: [PREPROD, "https://tm-two.bsstag.com"], + source: "env", + }); + }); + + it("drops an entry with no scheme, keeping the rest", async () => { + process.env.BROWSERSTACK_TM_BASE_URLS = `tm-preprod.bsstag.com,${PREPROD}`; + const { resolveTMBaseUrls } = await loadModule(false); + expect(resolveTMBaseUrls()).toEqual({ urls: [PREPROD], source: "env" }); + }); + + it("all garbage: falls back to the built-in list rather than probing nothing", async () => { + // An empty probe loop would surface as "unable to connect" with no detail. + process.env.BROWSERSTACK_TM_BASE_URLS = "not-a-url, ,???"; + const { apiClient, getTMBaseURL, resolveTMBaseUrls } = await loadModule(false); + expect(resolveTMBaseUrls()).toEqual({ + urls: BUILT_IN, + source: "built-in (override unusable)", + }); + + (apiClient.get as any).mockResolvedValue({ ok: false, status: 401 }); + await getTMBaseURL(mockConfig).catch(() => undefined); + expect(probed(apiClient)).toEqual(BUILT_IN.map((u) => `${u}/api/v2/projects/`)); + }); + + it("warns when it falls back, because silently using production is the danger", async () => { + process.env.BROWSERSTACK_TM_BASE_URLS = "not-a-url"; + const { apiClient, getTMBaseURL } = await loadModule(false); + const logger = (await import("../../src/logger")).default; + (apiClient.get as any).mockResolvedValue({ ok: false, status: 401 }); + await getTMBaseURL(mockConfig).catch(() => undefined); + + const warning = (logger.warn as any).mock.calls.map(String).join(" "); + expect(warning).toMatch(/BROWSERSTACK_TM_BASE_URLS/); + expect(warning).toMatch(/go to production/); + }); + + it("does not serve a cached URL minted under a DIFFERENT list", async () => { + // The cache is module-level; without keying it, an override would appear to work while + // quietly returning the previous environment's host. + const { apiClient, getTMBaseURL } = await loadModule(false); + (apiClient.get as any).mockResolvedValueOnce({ ok: true }); + expect(await getTMBaseURL(mockConfig)).toBe(BUILT_IN[0]); + expect(apiClient.get).toHaveBeenCalledTimes(1); + + // Same process, override now set: the cached production host must NOT come back. + process.env.BROWSERSTACK_TM_BASE_URLS = PREPROD; + (apiClient.get as any).mockResolvedValueOnce({ ok: true }); + expect(await getTMBaseURL(mockConfig)).toBe(PREPROD); + expect(apiClient.get).toHaveBeenCalledTimes(2); + + // ...and it still caches within one list. + expect(await getTMBaseURL(mockConfig)).toBe(PREPROD); + expect(apiClient.get).toHaveBeenCalledTimes(2); + }); +}); From bc61dd98837e8393b6f7f1a7090597ddc719f5d2 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Wed, 26 Aug 2026 11:05:10 +0530 Subject: [PATCH 17/31] Do not attempt the approval relay in the hosted deployment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This package publishes as @browserstack/mcp-server and remote-mcp-server consumes it, so a version bump hands the hosted multi-tenant server a tool built entirely for stdio. Three things break there. The callback listener binds an ephemeral loopback port per tool call, which in a shared process is one listener per concurrent call with a per-run bearer as the only thing keeping tenants apart. Atlas could not reach it regardless, because a 127.0.0.1 callback names the Atlas pod's own loopback and its SSRF allowlist refuses it. And elicitation is a server-initiated message, while the remote transport is stateless by deliberate design — 841c6358 removed sessions because they broke behind two replicas, and justified it on the grounds that nothing used server-initiated messages. This feature is the exception that commit did not have to consider. So in remote mode the listener is never bound — not bound and left to fail on a callback that cannot arrive — and permission_relay is omitted, which selects the read-only gate that already works and is already tested. The reasoning sits at the guard rather than in a report, including what re-enabling it would actually take, because the next person to try will otherwise spend a day rediscovering the session problem. The result says which of three things stopped the relay, since they need different responses: nobody could be asked, the request never arrived, or this deployment cannot receive a callback. The last one is new, and it deliberately outranks the first when both hold: in the hosted mode even a client that can be prompted is no use, so telling someone to switch clients would waste their time. It still loses to a request that never reached the agent, which is the more immediate fact. What was a boolean is now a three-valued mode, because it was never really a boolean. --- src/tools/ask-browserstack/register.ts | 74 +++++++++--- src/tools/ask-browserstack/relay.ts | 24 +++- src/tools/ask-browserstack/types.ts | 14 +++ tests/tools/askBrowserstack.test.ts | 57 +++++++++- tests/tools/askBrowserstackE2E.test.ts | 151 +++++++++++++++++++++++++ 5 files changed, 292 insertions(+), 28 deletions(-) diff --git a/src/tools/ask-browserstack/register.ts b/src/tools/ask-browserstack/register.ts index 48e077f..feff117 100644 --- a/src/tools/ask-browserstack/register.ts +++ b/src/tools/ask-browserstack/register.ts @@ -4,23 +4,24 @@ * * The shape, and why: * - * 1. NEGOTIATE FIRST. `getClientCapabilities()?.elicitation` is checked BEFORE Atlas is - * called, so Atlas learns whether a human is reachable before it starts rather than - * discovering it at the gate. No capability means `permission_relay` is omitted - * entirely and Atlas runs read-only — today's exact behaviour, and the path opencode - * and goose stay on. Nothing here depends on `sampling`, which Claude Code does not - * declare. + * 1. NEGOTIATE FIRST. `relayMode()` is consulted BEFORE Atlas is called, so Atlas learns + * whether a human is reachable before it starts rather than discovering it at the gate. + * Anything other than "offered" means `permission_relay` is omitted entirely and Atlas + * runs read-only — today's exact behaviour, and the path opencode and goose stay on. + * That also covers the hosted `REMOTE_MCP` deployment, where the relay cannot work at + * all; see `relayMode` for why. Nothing here depends on `sampling`, which Claude Code + * does not declare. * 2. LISTEN ON LOOPBACK. Transport is A2, so Atlas calls US back; because it initiates, * the decision returns on the same connection to the same pod and PLAN.md's affinity * problem never arises for this stdio deployment. - * 3. ELICIT, ONCE. Atlas's `description` is the message, `{ confirm: boolean }` the - * schema, and the answer is mapped by CONTRACT §7 with no second chances. + * 3. ELICIT, ONCE. Atlas's `description` is the message, nothing is requested in the form, + * and the ACTION is mapped by CONTRACT §7 with no second chances. * 4. RETURN THE TRAIL. `approvals` and `applied_before_stop` are what let a caller tell * "nothing happened" from "some steps applied, then stopped". * - * FAIL CLOSED THROUGHOUT. Only `accept` plus `confirm: true` is an allow. Everything else — - * a decline, a cancel, a timeout, a bad token, a body we cannot parse, a handler that throws - * — denies. + * FAIL CLOSED THROUGHOUT. A decline, a cancel, a timeout, a bad token, a body we cannot + * parse, a handler that throws — every one of them denies. An unattended run cannot approve + * itself because a headless client returns `cancel`, which is a deny. */ import { McpServer, RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js"; @@ -32,6 +33,7 @@ import { } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; +import appConfig from "../../config.js"; import { trackMCP } from "../../lib/instrumentation.js"; import { BrowserStackConfig } from "../../lib/types.js"; import logger from "../../logger.js"; @@ -67,6 +69,7 @@ import { PermissionAsk, PermissionDecision, PRODUCTS, + RelayMode, } from "./types.js"; export interface AskDeps { @@ -193,6 +196,41 @@ async function relayOneAsk( return { perm_id: ask.perm_id, decision, reason }; } +/** + * Decide whether to offer the approval channel at all — and in the hosted deployment, do not. + * + * THE RELAY IS A STDIO-ONLY FEATURE, and that is a designed property rather than an accident. + * Three things break in `REMOTE_MCP` mode, in increasing order of how hard they are to fix: + * + * 1. The callback listener binds `127.0.0.1:` PER TOOL CALL. In the shared, + * multi-tenant process that is N concurrent listeners on one host, with the per-run + * bearer as the only thing keeping tenants apart. + * 2. Atlas cannot reach it anyway. A `127.0.0.1` callback URL means the ATLAS POD'S OWN + * loopback, so every remote call is refused by its SSRF allowlist as `host_not_allowed`. + * Confirmed live against staging. + * 3. Elicitation is a SERVER-INITIATED message, and the remote `/mcp` is stateless BY + * DELIBERATE DESIGN. Commit `841c6358` removed sessions because they broke behind two + * replicas — "Session not found on roughly half of every client's post-handshake calls" + * — and justified it precisely on the grounds that "we use neither server-initiated + * messages nor subscriptions/sampling". This feature is the exception that commit did + * not have to consider, and it needs the machinery that commit removed. + * + * So do not start the listener and do not send `permission_relay`: Atlas then runs read-only, + * which is a supported path that already works. Attempting the relay instead would buy a slow + * and confusing failure in place of a clear one. + * + * BEFORE RE-ENABLING THIS IN REMOTE MODE: the blocker is (3), not configuration. It needs + * PLAN.md's option (c) — resolve the run in Postgres and nudge the pod holding the waiter over + * Redis, the pattern `POST /api/agent-callback/{correlation_id}` already uses — plus a callback + * URL that addresses a specific replica. A config flag will not do it. + */ +export function relayMode(server: McpServer): RelayMode { + if (appConfig.REMOTE_MCP) return "remote_mode"; + return server.server.getClientCapabilities()?.elicitation + ? "offered" + : "no_human"; +} + export function addAskBrowserstackAITool( server: McpServer, deps: AskDeps, @@ -238,9 +276,7 @@ export function addAskBrowserstackAITool( const approvals: ApprovalRecord[] = []; // Negotiated before anything else so the failure paths below report the mode they // would have run in. - const canElicit = Boolean( - server.server.getClientCapabilities()?.elicitation, - ); + const mode = relayMode(server); let listener: CallbackListener | undefined; try { @@ -260,7 +296,9 @@ export function addAskBrowserstackAITool( const username = (deps.credentialsFor().username || "").trim(); if (username) body.user_id = username; - if (canElicit) { + // NOT started at all in remote mode — see `relayMode`. Never bound, rather than + // bound and left to fail on a callback that cannot arrive. + if (mode === "offered") { listener = await startListener((ask) => relayOneAsk(server, ask, approvals), ); @@ -272,12 +310,12 @@ export function addAskBrowserstackAITool( // Omitted ENTIRELY, not sent empty: its absence is what selects Atlas's // read-only HeadlessGate. logger.info( - "askBrowserstackAI: client declares no elicitation capability; running " + - "read-only without a permission relay", + "askBrowserstackAI: no permission relay (%s); running read-only", + mode, ); } - return toResult(buildResult(await transport(url, headers, body), approvals, canElicit)); + return toResult(buildResult(await transport(url, headers, body), approvals, mode)); } catch (error) { const message = error instanceof AskError || error instanceof Error diff --git a/src/tools/ask-browserstack/relay.ts b/src/tools/ask-browserstack/relay.ts index 20fbb9c..a15500d 100644 --- a/src/tools/ask-browserstack/relay.ts +++ b/src/tools/ask-browserstack/relay.ts @@ -13,6 +13,7 @@ import { ASK_STATUSES, Decision, DecisionReason, + RelayMode, } from "./types.js"; export const RELAY_ON_DETAIL = @@ -52,6 +53,15 @@ export const RELAY_OFF_DETAILS: Record = { "That is a bug on this side, not something you did; everything in `needs_approval` was " + "refused because of it.", + // Neither the human nor the client is the constraint here — the DEPLOYMENT is, and no + // change either of them can make will help. + remote_mode: + "NOBODY DECLINED THIS, AND YOUR CLIENT IS NOT THE PROBLEM. This BrowserStack MCP server " + + "is running in its hosted, multi-tenant mode, which has no way to receive the approval " + + "callback, so it ran read-only and everything in `needs_approval` was refused for that " + + "reason alone. Mid-run approval works when the server runs locally over stdio; retrying " + + "against this deployment will keep failing the same way.", + // The request never got as far as the agent. Distinct from `disabled` (the agent ran, with // the relay switched off) and from a decline (someone was asked and said no), because the // three call for completely different things from whoever reads them. @@ -364,7 +374,7 @@ export function deriveStatus( */ function relayVerdict( payload: Record, - relayUsed: boolean, + mode: RelayMode, reachedAgent: boolean, ): AskResult["permission_relay"] { // FIRST, because it outranks both of the cases below. If the request never reached the @@ -379,7 +389,13 @@ function relayVerdict( detail: RELAY_OFF_DETAILS.not_reached, }; } - if (!relayUsed) { + // BEFORE `no_human`, deliberately, when both are true. In the hosted deployment even a + // client that CAN be prompted is of no use, so the deployment is the binding constraint and + // the one the reader can act on; telling them to switch clients would waste their time. + if (mode === "remote_mode") { + return { used: false, reason: "remote_mode", detail: RELAY_OFF_DETAILS.remote_mode }; + } + if (mode === "no_human") { // CONTRACT §7's last row: no elicitation capability means the field was never sent. return { used: false, reason: "no_human", detail: RELAY_OFF_DETAILS.no_human }; } @@ -397,7 +413,7 @@ function relayVerdict( export function buildResult( response: AgentResponse, approvals: ApprovalRecord[], - relayUsed: boolean, + mode: RelayMode, ): AskResult { const payload = asRecord(response.body); // ABSENT WHEN EMPTY, never `[]` (v1.1 §B): Atlas's `public()` omits the key entirely, as @@ -425,7 +441,7 @@ export function buildResult( elicitations: withOutcomes(approvals), needs_approval: needsApproval, applied_before_stop: readAppliedBeforeStop(payload), - permission_relay: relayVerdict(payload, relayUsed, reachedAgent), + permission_relay: relayVerdict(payload, mode, reachedAgent), atlas_response: response.body ?? null, ...atlasError(response, payload), }; diff --git a/src/tools/ask-browserstack/types.ts b/src/tools/ask-browserstack/types.ts index 0602632..84d6a69 100644 --- a/src/tools/ask-browserstack/types.ts +++ b/src/tools/ask-browserstack/types.ts @@ -118,6 +118,20 @@ export interface ApprovalRecord { outcome?: string; } +/** + * Whether the approval channel was offered to Atlas at all, and if not, why not. + * + * Three different facts about this deployment and this client, each needing a different + * thing from whoever reads the result — so they are three values rather than one boolean. + */ +export type RelayMode = + /** A callback listener was bound and `permission_relay` was sent. */ + | "offered" + /** The client declares no `elicitation` capability, so nobody could be prompted. */ + | "no_human" + /** This process is the hosted multi-tenant server, which cannot receive the callback. */ + | "remote_mode"; + export const ASK_STATUSES = ["ok", "blocked", "error", "rate_limited"] as const; export type AskStatus = (typeof ASK_STATUSES)[number]; diff --git a/tests/tools/askBrowserstack.test.ts b/tests/tools/askBrowserstack.test.ts index 47b1b57..e8dd2ad 100644 --- a/tests/tools/askBrowserstack.test.ts +++ b/tests/tools/askBrowserstack.test.ts @@ -33,7 +33,7 @@ import { neverReachedAgent, UNAUTHENTICATED_DETAIL, } from "../../src/tools/ask-browserstack/relay.js"; -import { ApprovalRecord } from "../../src/tools/ask-browserstack/types.js"; +import { ApprovalRecord, RelayMode } from "../../src/tools/ask-browserstack/types.js"; const PERM = "perm-" + "a".repeat(32); @@ -338,7 +338,7 @@ describe("result assembly", () => { it("explains a read-only run rather than leaving a refused write unexplained", () => { const result = buildResult( { status: 200, body: { status: "blocked", answer: "", needs_approval: ["create folder"] } }, - [], false, + [], "no_human", ); expect(result.status).toBe("blocked"); expect(result.needs_approval).toEqual(["create folder"]); @@ -534,10 +534,10 @@ describe("the verified /agent response shape — CONTRACT v1.1 §B", () => { }); describe("Atlas's permission_relay verdict — CONTRACT v1.1 §D", () => { - function relayOf(permission_relay: unknown, relayUsed = true) { + function relayOf(permission_relay: unknown, mode: RelayMode = "offered") { return buildResult( { status: 200, body: { status: "blocked", answer: "", permission_relay } }, - [], relayUsed, + [], mode, ).permission_relay; } @@ -595,7 +595,7 @@ describe("Atlas's permission_relay verdict — CONTRACT v1.1 §D", () => { it("keeps no_human ours: Atlas is never told the client cannot be prompted", () => { // We omitted the block, so anything Atlas says about a relay cannot be about this run. - const relay = relayOf({ used: true, reason: "" }, false); + const relay = relayOf({ used: true, reason: "" }, "no_human"); expect(relay).toEqual({ used: false, reason: "no_human", @@ -604,6 +604,51 @@ describe("Atlas's permission_relay verdict — CONTRACT v1.1 §D", () => { }); }); +describe("REMOTE_MCP — the relay is a stdio-only feature", () => { + function relayOf(mode: RelayMode, permission_relay?: unknown) { + return buildResult( + { status: 200, body: { status: "blocked", answer: "", permission_relay } }, + [], mode, + ).permission_relay; + } + + it("says the DEPLOYMENT is why, not the human and not the client", () => { + const relay = relayOf("remote_mode"); + expect(relay).toEqual({ + used: false, + reason: "remote_mode", + detail: expect.stringContaining("NOBODY DECLINED THIS, AND YOUR CLIENT IS NOT THE PROBLEM"), + }); + expect(relay.detail).toMatch(/hosted, multi-tenant mode/); + expect(relay.detail).toMatch(/works when the server runs locally over stdio/); + }); + + it("reads differently from every other reason", () => { + const remote = relayOf("remote_mode").detail; + const noHuman = relayOf("no_human").detail; + const notReached = buildResult( + { status: 401, body: { detail: "unauthorized" } }, [], "remote_mode", + ).permission_relay.detail; + const disabled = relayOf("offered", { used: false, reason: "disabled" }).detail; + expect(new Set([remote, noHuman, notReached, disabled]).size).toBe(4); + // Telling a hosted user to switch to a client that can be prompted would waste their time. + expect(remote).not.toMatch(/does not support MCP elicitation/); + }); + + it("beats no_human, because switching clients cannot help a hosted deployment", () => { + // Both facts can be true at once. The deployment is the binding constraint and the only + // one the reader can act on. + expect(relayOf("remote_mode").reason).toBe("remote_mode"); + }); + + it("still loses to not_reached: a request that never arrived says so first", () => { + const relay = buildResult( + { status: 401, body: { detail: "unauthorized" } }, [], "remote_mode", + ).permission_relay; + expect(relay.reason).toBe("not_reached"); + }); +}); + describe("the elicitation message — CONTRACT v1.1 §G", () => { it("names the product and leaves the description untouched", () => { const message = elicitationMessage("tm", "Create folder \"Regression\"."); @@ -979,7 +1024,7 @@ describe("pre-run refusals — the request never reached the agent (D3)", () => // Saying the run went read-only would be as wrong as saying the channel was used — // nothing ran at all. The elicitation gap resurfaces on the next run. const relay = buildResult( - { status: 401, body: { detail: "unauthorized" } }, [], false, + { status: 401, body: { detail: "unauthorized" } }, [], "no_human", ).permission_relay; expect(relay.reason).toBe("not_reached"); expect(relay.detail).not.toMatch(/does not support MCP elicitation/); diff --git a/tests/tools/askBrowserstackE2E.test.ts b/tests/tools/askBrowserstackE2E.test.ts index 590161d..ea289db 100644 --- a/tests/tools/askBrowserstackE2E.test.ts +++ b/tests/tools/askBrowserstackE2E.test.ts @@ -941,6 +941,157 @@ describe("askBrowserstackAI, end to end through the server factory", () => { }); }); + describe("REMOTE_MCP — the hosted deployment must not attempt the relay", () => { + /** + * `appConfig` reads `process.env.REMOTE_MCP` once at module load, so the whole graph is + * re-imported with the env in place — the same trick `tests/lib/tm-base-url.test.ts` uses. + */ + async function buildRemoteServer() { + vi.resetModules(); + process.env.REMOTE_MCP = "true"; + const { BrowserStackMcpServer } = await import("../../src/server-factory.js"); + return new BrowserStackMcpServer(CONFIG); + } + + afterEach(() => { + delete process.env.REMOTE_MCP; + vi.resetModules(); + }); + + it("never binds a listener, omits permission_relay, and says why", async () => { + const server = await buildRemoteServer(); + // A client that CAN be prompted — this is the case where remote_mode has to beat + // no_human, because switching clients would not help. + const elicit = fakeClient(server.getInstance(), { roots: {}, elicitation: {} }, []); + const stub = atlas({ + payload: () => ({ + ok: true, status: "blocked", answer: "I could not create the folder.", steps: [], + needs_approval: ["Create folder \"Regression\"."], + }), + }); + + const { result, payload } = await call(server.getTools()); + + // 1. nothing was offered to Atlas + expect("permission_relay" in stub.calls[0].body).toBe(false); + expect(Object.keys(stub.calls[0].body).sort()) + .toEqual(["product", "task", "user_id"]); + // 2. nothing was ever asked + expect(elicit).not.toHaveBeenCalled(); + // 3. the result blames the deployment, not the human and not the client + expect(payload.permission_relay).toEqual({ + used: false, + reason: "remote_mode", + detail: expect.stringContaining("hosted, multi-tenant mode"), + }); + expect(payload.permission_relay.detail) + .not.toMatch(/does not support MCP elicitation/); + // 4. a read-only run is not a tool failure + expect(result.isError).toBeUndefined(); + expect(payload.needs_approval).toEqual(["Create folder \"Regression\"."]); + }); + + it("binds no port at all — the listener is never even constructed", async () => { + // Not "bound and left to fail on a callback that cannot arrive": never bound. In the + // shared process that would be one ephemeral listener per concurrent tool call. + // + // Asserted through the injected seam rather than a module spy, so a negative result + // means the code did not call it — not that the spy failed to attach. The positive + // control below is what makes this assertion mean anything. + vi.resetModules(); + process.env.REMOTE_MCP = "true"; + const { addAskBrowserstackAITool } = await import( + "../../src/tools/ask-browserstack/register.js" + ); + const { McpServer: RemoteMcpServer } = await import( + "@modelcontextprotocol/sdk/server/mcp.js" + ); + + const startListener = vi.fn(async () => ({ + url: "http://127.0.0.1:1/atlas-permission", token: "t", close: async () => {}, + })); + const transport = vi.fn(async () => ({ status: 200, body: { status: "ok", answer: "" } })); + + const remote = new RemoteMcpServer({ name: "t", version: "0" }); + vi.spyOn(remote.server, "getClientCapabilities") + .mockReturnValue({ elicitation: {} } as never); + const tools = addAskBrowserstackAITool(remote, { + agentUrl: () => "https://atlas.example/agent", + mintToken: async () => MINTED, + credentialsFor: () => ({ username: "ing_Xx", accessKey: "SECRET" }), + transport: transport as never, + startListener: startListener as never, + }); + + const { payload } = await call(tools); + expect(startListener).not.toHaveBeenCalled(); + expect("permission_relay" in (transport.mock.calls[0] as any)[2]).toBe(false); + expect(payload.permission_relay.reason).toBe("remote_mode"); + }); + + it("positive control: the same seam IS called when not in remote mode", async () => { + // Without this, the assertion above would pass just as happily if the seam were + // broken and nothing ever called it. + vi.resetModules(); + delete process.env.REMOTE_MCP; + const { addAskBrowserstackAITool } = await import( + "../../src/tools/ask-browserstack/register.js" + ); + const { McpServer: StdioMcpServer } = await import( + "@modelcontextprotocol/sdk/server/mcp.js" + ); + + const startListener = vi.fn(async () => ({ + url: "http://127.0.0.1:1/atlas-permission", token: "t", close: async () => {}, + })); + const transport = vi.fn(async () => ({ status: 200, body: { status: "ok", answer: "" } })); + + const stdio = new StdioMcpServer({ name: "t", version: "0" }); + vi.spyOn(stdio.server, "getClientCapabilities") + .mockReturnValue({ elicitation: {} } as never); + const tools = addAskBrowserstackAITool(stdio, { + agentUrl: () => "https://atlas.example/agent", + mintToken: async () => MINTED, + credentialsFor: () => ({ username: "ing_Xx", accessKey: "SECRET" }), + transport: transport as never, + startListener: startListener as never, + }); + + await call(tools); + expect(startListener).toHaveBeenCalledTimes(1); + expect((transport.mock.calls[0] as any)[2].permission_relay).toBeDefined(); + }); + + it("stdio does not regress: the listener still binds and the block is still sent", async () => { + // REMOTE_MCP unset — byte-identical to every other test in this file. + const server = await buildServer(); + fakeClient(server.getInstance(), { elicitation: {} }, [ + { action: "accept" }, + ]); + const stub = atlas({ asks: [{ perm_id: PERM_A, description: "Create folder." }] }); + + const { payload } = await call(server.getTools()); + expect(stub.calls[0].body.permission_relay.callback_url) + .toMatch(/^http:\/\/127\.0\.0\.1:\d+\/atlas-permission$/); + expect(stub.calls[0].body.permission_relay.token).toHaveLength(64); + expect(payload.permission_relay.used).toBe(true); + expect(payload.permission_relay.reason).toBe(""); + }); + + it("REMOTE_MCP=\"false\" is stdio, not remote", async () => { + vi.resetModules(); + process.env.REMOTE_MCP = "false"; + const { BrowserStackMcpServer } = await import("../../src/server-factory.js"); + const server = new BrowserStackMcpServer(CONFIG); + fakeClient(server.getInstance(), { elicitation: {} }, [{ action: "accept" }]); + const stub = atlas({ asks: [{ perm_id: PERM_A, description: "Create folder." }] }); + + const { payload } = await call(server.getTools()); + expect(stub.calls[0].body.permission_relay).toBeDefined(); + expect(payload.permission_relay.used).toBe(true); + }); + }); + it("refuses by name when no host is configured, without calling anything", async () => { delete process.env.ASK_BROWSERSTACK_ATLAS_URL; const fetchSpy = vi.fn(); From 420c20ebe1cef131a8d4393a0b6c9a9e09f6a8ed Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Wed, 26 Aug 2026 11:20:51 +0530 Subject: [PATCH 18/31] Ship the Atlas hosts, so an install needs a name and not a URL MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This was the only tool here requiring install-time host configuration. Every other one carries its production host in the code and treats env vars as an override, and the capability registry's resolveBaseUrl is the richer form of the same idea; this is that, for Atlas. Both existing overrides stay ahead of the map, so nothing already working stops working. Each host was established rather than assumed, and the comment says how: prod answers the same 401 as staging Atlas and 404s on /agent only because it runs an image without the delegation route yet; stag and preprod are the two ingress hosts serving one backend. Staging pointing at preprod's auth server looks like a copy-paste and is not — preprod is configured there as an extra environment and accepted configs include extras, so a preprod-minted token validates against staging, which was confirmed live rather than reasoned about. An unset environment refuses instead of defaulting to production. The registry already refuses when an environment is named but has no host, on the grounds that falling back would silently send a preprod deployment at production, and that argument only gets stronger for a tool that writes. The cost of refusing is one environment name at install time, reported by name the first time the tool runs; the cost of defaulting is an unconfigured install quietly changing production data. A selector that is wrong refuses by name, where a URL that is wrong talks to the wrong place in silence — which is the whole reason to prefer naming an environment over pasting a host. --- src/tools/ask-browserstack/config.ts | 126 +++++++++++++++++++++------ tests/tools/askBrowserstack.test.ts | 110 ++++++++++++++++++++++- 2 files changed, 208 insertions(+), 28 deletions(-) diff --git a/src/tools/ask-browserstack/config.ts b/src/tools/ask-browserstack/config.ts index 9a604e8..5844390 100644 --- a/src/tools/ask-browserstack/config.ts +++ b/src/tools/ask-browserstack/config.ts @@ -44,32 +44,105 @@ export function selectedEnvironment(): string { ).trim(); } +/** + * The hosts this tool ships with, so an install needs an ENVIRONMENT NAME and not a URL. + * + * Every other tool here bakes its production host into the code and treats env vars as an + * override — `TM_BASE_URLS`, the instrumentation endpoint — and the capability registry's + * `resolveBaseUrl` is the richer form of the same idea. This is that, for Atlas. + * + * Each pair was established rather than assumed: + * + * - `prod` — `workflows.browserstack.com/api/profiles` answers + * `401 {"detail":"authentication required"}`, byte-identical to staging Atlas. `/agent` + * 404s there only because prod runs an image without the delegation route yet, and `/fe` + * 404s because prod is API-only by design. + * - `stag` / `preprod` — the two ingress hosts in `ai-platform-infra-ops` `stag` + * `values-atlas.yaml`, both serving the same `atlas-server` backend. + * - `stag` pointing at **auth-preprod is deliberate, not a copy-paste.** Staging's + * `oauth.issuer` is `auth-rengg-reg-ai-agent-dev.bsstag.com`, but preprod is configured + * as an EXTRA ENVIRONMENT and `_accepted_configs` returns the default PLUS extras, so a + * preprod-minted token validates there. Confirmed live: a token minted at `auth-preprod` + * was accepted by staging Atlas (`matched=scope`, verified `user_id`). + */ +export const ATLAS_HOSTS: Record = { + prod: { + agent: "https://workflows.browserstack.com", + auth: "https://auth.browserstack.com/oauth2/v2/token", + }, + preprod: { + agent: "https://ai-platform-service-preprod.bsstag.com", + auth: "https://auth-preprod.bsstag.com/oauth2/v2/token", + }, + stag: { + agent: "https://ai-platform-service.bsstag.com", + auth: "https://auth-preprod.bsstag.com/oauth2/v2/token", + }, +}; + +const KNOWN_ENVIRONMENTS = Object.keys(ATLAS_HOSTS).join(", "); + +/** Map entries are literals and an operator's override may not be; normalise both. */ +function trimUrl(value: string): string { + return value.trim().replace(/\/+$/, ""); +} + +/** + * Why an unset environment REFUSES rather than defaulting to production. + * + * The capability registry refuses when an environment is named but has no host, because + * *"falling back to the harness default here would send a preprod deployment at production, + * silently."* The same reasoning applies harder to an environment that was never named at + * all, and hardest of all to this tool, because this one WRITES. + * + * The cost of refusing is one documented environment name at install time, reported by name + * the first time the tool is used. The cost of defaulting is an unconfigured install quietly + * changing production data. Those are not comparable, and a selector that is wrong refuses + * by name where a URL that is wrong talks to the wrong place in silence. + */ +function noEnvironment(explicitVar: string): AskError { + return new AskError( + `no BrowserStack AI environment is selected. Set ASK_BROWSERSTACK_ENV to one of ` + + `${KNOWN_ENVIRONMENTS} — or set ${explicitVar} to a host directly. Nothing is ` + + `assumed here on purpose: this tool can change data, so it will not guess at ` + + `production.`, + ); +} + +function unknownEnvironment(environment: string, suffixedVar: string): AskError { + return new AskError( + `environment '${environment}' has no built-in BrowserStack AI host. Known ` + + `environments: ${KNOWN_ENVIRONMENTS}. Set ${suffixedVar} to add one.`, + ); +} + /** * Resolve Atlas's base URL: * * 1. ASK_BROWSERSTACK_ATLAS_URL explicit, environment-agnostic * 2. ASK_BROWSERSTACK_ATLAS_URL_ this environment's host - * 3. refuse, by name + * 3. the built-in map for + * 4. refuse, by name * - * Refusing rather than guessing is the point of rung 3. + * The overrides stay ahead of the map so nothing that works today stops working. */ export function atlasBaseUrl(): string { const explicit = process.env.ASK_BROWSERSTACK_ATLAS_URL; - if (explicit && explicit.trim()) return explicit.trim().replace(/\/$/, ""); + if (explicit && explicit.trim()) return trimUrl(explicit); const environment = selectedEnvironment(); - if (environment) { - const suffixed = - process.env[`ASK_BROWSERSTACK_ATLAS_URL_${environment.toUpperCase()}`]; - if (suffixed && suffixed.trim()) return suffixed.trim().replace(/\/$/, ""); + if (!environment) { + throw noEnvironment("ASK_BROWSERSTACK_ATLAS_URL"); } - throw new AskError( - "no host is configured for BrowserStack AI: set ASK_BROWSERSTACK_ATLAS_URL" + - (environment - ? ` or ASK_BROWSERSTACK_ATLAS_URL_${environment.toUpperCase()}` - : ""), - ); + const suffixedVar = `ASK_BROWSERSTACK_ATLAS_URL_${environment.toUpperCase()}`; + const suffixed = process.env[suffixedVar]; + if (suffixed && suffixed.trim()) return trimUrl(suffixed); + + const builtIn = ATLAS_HOSTS[environment.toLowerCase()]; + if (builtIn) return trimUrl(builtIn.agent); + + throw unknownEnvironment(environment, suffixedVar); } /** Resolved per call, never captured at construction. */ @@ -83,25 +156,24 @@ export function agentUrl(): string { * The shared `delegation.token` path is gone from Atlas, so a user-attested central JWT is * now the only way in. This is the endpoint that issues one from a username and access key. * - * Same precedence rungs as the host, and refusing rather than guessing for the same reason: - * a deployment pointing at preprod must not sign in against production's auth server. + * Same four rungs as the host, and refusing rather than guessing for the same reason: a + * deployment pointing at preprod must not sign in against production's auth server. */ export function authTokenUrl(): string { const explicit = process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL; - if (explicit && explicit.trim()) return explicit.trim(); + if (explicit && explicit.trim()) return trimUrl(explicit); const environment = selectedEnvironment(); - if (environment) { - const suffixed = - process.env[`ASK_BROWSERSTACK_AUTH_TOKEN_URL_${environment.toUpperCase()}`]; - if (suffixed && suffixed.trim()) return suffixed.trim(); + if (!environment) { + throw noEnvironment("ASK_BROWSERSTACK_AUTH_TOKEN_URL"); } - throw new AskError( - "BrowserStack AI has nowhere to sign in: set ASK_BROWSERSTACK_AUTH_TOKEN_URL" + - (environment - ? ` or ASK_BROWSERSTACK_AUTH_TOKEN_URL_${environment.toUpperCase()}` - : "") + - " to the BrowserStack OAuth token endpoint", - ); + const suffixedVar = `ASK_BROWSERSTACK_AUTH_TOKEN_URL_${environment.toUpperCase()}`; + const suffixed = process.env[suffixedVar]; + if (suffixed && suffixed.trim()) return trimUrl(suffixed); + + const builtIn = ATLAS_HOSTS[environment.toLowerCase()]; + if (builtIn) return trimUrl(builtIn.auth); + + throw unknownEnvironment(environment, suffixedVar); } diff --git a/tests/tools/askBrowserstack.test.ts b/tests/tools/askBrowserstack.test.ts index e8dd2ad..bb142c0 100644 --- a/tests/tools/askBrowserstack.test.ts +++ b/tests/tools/askBrowserstack.test.ts @@ -7,7 +7,7 @@ import { startCallbackListener, } from "../../src/tools/ask-browserstack/callback.js"; import { - AskError, atlasBaseUrl, authTokenUrl, + ATLAS_HOSTS, AskError, atlasBaseUrl, authTokenUrl, } from "../../src/tools/ask-browserstack/config.js"; import { AUTH_REJECTED_DETAIL, @@ -1219,3 +1219,111 @@ describe("a 2xx carrying no delegation result is internally consistent (N4)", () } }); }); + +describe("the built-in Atlas host map", () => { + const saved = { ...process.env }; + + beforeEach(() => { + // Nothing but the selector: the whole point is that an install needs no URL. + delete process.env.ASK_BROWSERSTACK_ATLAS_URL; + delete process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL; + delete process.env.ASK_BROWSERSTACK_ENV; + delete process.env.CAPABILITY_REGISTRY_ENV; + }); + + afterEach(() => { + process.env = { ...saved }; + }); + + it.each([ + ["prod", "https://workflows.browserstack.com", "https://auth.browserstack.com/oauth2/v2/token"], + ["preprod", "https://ai-platform-service-preprod.bsstag.com", "https://auth-preprod.bsstag.com/oauth2/v2/token"], + ["stag", "https://ai-platform-service.bsstag.com", "https://auth-preprod.bsstag.com/oauth2/v2/token"], + ])("resolves %s from the selector alone", (env, agent, auth) => { + process.env.ASK_BROWSERSTACK_ENV = env; + expect(atlasBaseUrl()).toBe(agent); + expect(authTokenUrl()).toBe(auth); + }); + + it("sends staging at auth-preprod on purpose, not by copy-paste", () => { + // Staging's own issuer is auth-rengg-reg-ai-agent-dev, but preprod is configured as an + // extra environment and `_accepted_configs` returns the default PLUS extras, so a + // preprod-minted token validates there. Confirmed live (matched=scope). + expect(ATLAS_HOSTS.stag.auth).toBe(ATLAS_HOSTS.preprod.auth); + expect(ATLAS_HOSTS.stag.agent).not.toBe(ATLAS_HOSTS.preprod.agent); + }); + + it("takes the selector from CAPABILITY_REGISTRY_ENV too, without a third variable", () => { + process.env.CAPABILITY_REGISTRY_ENV = "preprod"; + expect(atlasBaseUrl()).toBe("https://ai-platform-service-preprod.bsstag.com"); + }); + + it("is case-insensitive about the environment name", () => { + process.env.ASK_BROWSERSTACK_ENV = "PreProd"; + expect(atlasBaseUrl()).toBe("https://ai-platform-service-preprod.bsstag.com"); + }); + + it("lets an explicit override beat the map", () => { + process.env.ASK_BROWSERSTACK_ENV = "prod"; + process.env.ASK_BROWSERSTACK_ATLAS_URL = "https://atlas.example/"; + process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL = "https://auth.example/t/"; + expect(atlasBaseUrl()).toBe("https://atlas.example"); + expect(authTokenUrl()).toBe("https://auth.example/t"); + }); + + it("lets an env-suffixed override beat the map", () => { + process.env.ASK_BROWSERSTACK_ENV = "prod"; + process.env.ASK_BROWSERSTACK_ATLAS_URL_PROD = "https://atlas-pinned.example"; + process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL_PROD = "https://auth-pinned.example/t"; + expect(atlasBaseUrl()).toBe("https://atlas-pinned.example"); + expect(authTokenUrl()).toBe("https://auth-pinned.example/t"); + }); + + it("strips trailing slashes from overrides, since the map entries carry none", () => { + process.env.ASK_BROWSERSTACK_ENV = "prod"; + process.env.ASK_BROWSERSTACK_ATLAS_URL = "https://atlas.example///"; + // Otherwise `${base}/agent` becomes `//agent`. + expect(atlasBaseUrl()).toBe("https://atlas.example"); + for (const host of Object.values(ATLAS_HOSTS)) { + expect(host.agent.endsWith("/")).toBe(false); + expect(host.auth.endsWith("/")).toBe(false); + } + }); + + it("refuses an environment the map has never heard of, by name", () => { + process.env.ASK_BROWSERSTACK_ENV = "dev"; + expect(() => atlasBaseUrl()).toThrow(AskError); + expect(() => atlasBaseUrl()).toThrow(/environment 'dev' has no built-in/); + // ...and names both the known set and the way to add one. + expect(() => atlasBaseUrl()).toThrow(/prod, preprod, stag/); + expect(() => atlasBaseUrl()).toThrow(/ASK_BROWSERSTACK_ATLAS_URL_DEV/); + expect(() => authTokenUrl()).toThrow(/ASK_BROWSERSTACK_AUTH_TOKEN_URL_DEV/); + }); + + it("REFUSES when no environment is selected — it does not default to production", () => { + // The decision, asserted deliberately so it cannot drift. An unconfigured install + // quietly writing to production Atlas is the failure the capability registry's own + // comment warns about, and this tool changes data. + expect(() => atlasBaseUrl()).toThrow(AskError); + expect(() => atlasBaseUrl()).toThrow(/no BrowserStack AI environment is selected/); + expect(() => atlasBaseUrl()).toThrow(/will not guess at production/); + expect(() => authTokenUrl()).toThrow(/no BrowserStack AI environment is selected/); + + // Specifically: NOT the prod host, even though the map contains one. + let resolved: string | undefined; + try { + resolved = atlasBaseUrl(); + } catch { + resolved = undefined; + } + expect(resolved).toBeUndefined(); + }); + + it("still works with only an explicit URL and no selector at all", () => { + // The pre-existing escape hatch keeps working for anyone already using it. + process.env.ASK_BROWSERSTACK_ATLAS_URL = "https://atlas.example"; + process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL = "https://auth.example/t"; + expect(atlasBaseUrl()).toBe("https://atlas.example"); + expect(authTokenUrl()).toBe("https://auth.example/t"); + }); +}); From ec58c62e669dedaada74b7d2225ec31a3eae7284 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Wed, 26 Aug 2026 11:25:54 +0530 Subject: [PATCH 19/31] Hardcode one staging host, and say loudly that it is temporary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three-environment map and its selector go away. The decision is to ship a single hardcoded staging host for now and repoint at production later, so that is what this does: one default, one override, no map and no ASK_BROWSERSTACK_ENV. That also brings this tool into line with every other one here, which carries its host in the code and treats the env var as an escape hatch. A hardcoded default removes the refusal that task 13 added, and the argument for that refusal was about production: an unconfigured install quietly writing to real data. Defaulting to staging inverts it — an unconfigured install cannot touch production — but it is still wrong for a production deployment, which would read and write the wrong environment's data without any sign that it was doing so. So the resolved host is announced at info on first use, naming whether it came from the environment or from the constant, once rather than per call so it is not something to scroll past. A deployment pointing at the wrong Atlas should cost one log line to notice, not a confusing afternoon. The constant carries the rest: that this is an interim placeholder, that production is workflows.browserstack.com with its own auth endpoint and how that host was verified, that publishing to npm means an install with no configuration talks to staging, and that both the constants and the tests asserting them must change before production users get the tool. The tests assert the literals for exactly that reason — repointing should be a deliberate edit rather than something that passes quietly. The module docblock claimed no host was compiled in, which is now the opposite of what the code does, so it says what the code does instead. --- src/tools/ask-browserstack/config.ts | 171 +++++++------------ tests/tools/askBrowserstack.test.ts | 224 ++++++++++--------------- tests/tools/askBrowserstackE2E.test.ts | 72 +++++--- 3 files changed, 196 insertions(+), 271 deletions(-) diff --git a/src/tools/ask-browserstack/config.ts b/src/tools/ask-browserstack/config.ts index 5844390..8fedd82 100644 --- a/src/tools/ask-browserstack/config.ts +++ b/src/tools/ask-browserstack/config.ts @@ -1,10 +1,12 @@ +import logger from "../../logger.js"; + /** * Where Atlas lives, and the timeout ladder. * - * No host is compiled in. A guessed host fails as a DNS error or a 404 that reads like the - * caller's problem when it is our missing configuration, and a hardcoded default would send - * a preprod deployment at production — the same reasoning as the capability registry's - * `resolveBaseUrl`, and the same precedence rungs. + * The host IS compiled in, matching every other tool here — `TM_BASE_URLS`, the + * instrumentation endpoint — so an install needs no configuration to work. One env var + * overrides it. See the warning on `DEFAULT_ATLAS_URL`: the compiled-in value is currently + * STAGING and is a deliberate placeholder. */ /** @@ -30,119 +32,78 @@ export function isEnabled(): boolean { } /** - * The environment this DEPLOYMENT points at, e.g. "preprod". + * ============================================================================ + * TEMPORARY STAGING DEFAULT — REPOINT BEFORE PRODUCTION USERS GET THIS TOOL + * ============================================================================ * - * `CAPABILITY_REGISTRY_ENV` is honoured as a fallback on purpose: it is the same deployment - * pointing at the same environment, and making an operator state that fact twice is how the - * two drift. - */ -export function selectedEnvironment(): string { - return ( - process.env.ASK_BROWSERSTACK_ENV || - process.env.CAPABILITY_REGISTRY_ENV || - "" - ).trim(); -} - -/** - * The hosts this tool ships with, so an install needs an ENVIRONMENT NAME and not a URL. + * These hosts are STAGING. They are hardcoded on purpose, as an explicit interim step: + * "for now lets hardcode the base_url to staging only then we will point this to prod url + * later." This is a placeholder, not the end state. * - * Every other tool here bakes its production host into the code and treats env vars as an - * override — `TM_BASE_URLS`, the instrumentation endpoint — and the capability registry's - * `resolveBaseUrl` is the richer form of the same idea. This is that, for Atlas. + * PRODUCTION IS `https://workflows.browserstack.com` — verified, not guessed: its + * `/api/profiles` answers `401 {"detail":"authentication required"}`, byte-identical to + * staging Atlas. (`/agent` 404s there today only because prod runs an image without the + * delegation route yet, and `/fe` 404s because prod is API-only by design.) The production + * auth endpoint is `https://auth.browserstack.com/oauth2/v2/token`. * - * Each pair was established rather than assumed: + * WHY THIS MATTERS: this package publishes to npm as `@browserstack/mcp-server`, so an + * install with no environment variables set talks to STAGING. That is the safer direction — + * it cannot touch production data — but it is still wrong for a production deployment, which + * would silently read and write the wrong environment's data. The resolved host is therefore + * logged at info on first use, naming whether it came from the env var or from here, so a + * deployment pointing at the wrong Atlas is visible in a log line rather than inferred later + * from confusing data. * - * - `prod` — `workflows.browserstack.com/api/profiles` answers - * `401 {"detail":"authentication required"}`, byte-identical to staging Atlas. `/agent` - * 404s there only because prod runs an image without the delegation route yet, and `/fe` - * 404s because prod is API-only by design. - * - `stag` / `preprod` — the two ingress hosts in `ai-platform-infra-ops` `stag` - * `values-atlas.yaml`, both serving the same `atlas-server` backend. - * - `stag` pointing at **auth-preprod is deliberate, not a copy-paste.** Staging's - * `oauth.issuer` is `auth-rengg-reg-ai-agent-dev.bsstag.com`, but preprod is configured - * as an EXTRA ENVIRONMENT and `_accepted_configs` returns the default PLUS extras, so a - * preprod-minted token validates there. Confirmed live: a token minted at `auth-preprod` - * was accepted by staging Atlas (`matched=scope`, verified `user_id`). + * BEFORE SHIPPING TO PRODUCTION USERS: change these two constants, and change the tests that + * assert them — they assert the literals precisely so that repointing has to be deliberate + * rather than something that slips through. + * + * grep: TEMPORARY-STAGING-DEFAULT */ -export const ATLAS_HOSTS: Record = { - prod: { - agent: "https://workflows.browserstack.com", - auth: "https://auth.browserstack.com/oauth2/v2/token", - }, - preprod: { - agent: "https://ai-platform-service-preprod.bsstag.com", - auth: "https://auth-preprod.bsstag.com/oauth2/v2/token", - }, - stag: { - agent: "https://ai-platform-service.bsstag.com", - auth: "https://auth-preprod.bsstag.com/oauth2/v2/token", - }, -}; - -const KNOWN_ENVIRONMENTS = Object.keys(ATLAS_HOSTS).join(", "); +export const DEFAULT_ATLAS_URL = "https://ai-platform-service.bsstag.com"; +export const DEFAULT_AUTH_TOKEN_URL = + "https://auth-preprod.bsstag.com/oauth2/v2/token"; -/** Map entries are literals and an operator's override may not be; normalise both. */ +/** An operator's override may carry a trailing slash; the constants above do not. */ function trimUrl(value: string): string { return value.trim().replace(/\/+$/, ""); } /** - * Why an unset environment REFUSES rather than defaulting to production. + * Announced ONCE per distinct resolution, not per tool call. * - * The capability registry refuses when an environment is named but has no host, because - * *"falling back to the harness default here would send a preprod deployment at production, - * silently."* The same reasoning applies harder to an environment that was never named at - * all, and hardest of all to this tool, because this one WRITES. - * - * The cost of refusing is one documented environment name at install time, reported by name - * the first time the tool is used. The cost of defaulting is an unconfigured install quietly - * changing production data. Those are not comparable, and a selector that is wrong refuses - * by name where a URL that is wrong talks to the wrong place in silence. + * The point is that a deployment talking to the wrong Atlas shows up in the log; repeating it + * on every call would only make it easier to scroll past. */ -function noEnvironment(explicitVar: string): AskError { - return new AskError( - `no BrowserStack AI environment is selected. Set ASK_BROWSERSTACK_ENV to one of ` + - `${KNOWN_ENVIRONMENTS} — or set ${explicitVar} to a host directly. Nothing is ` + - `assumed here on purpose: this tool can change data, so it will not guess at ` + - `production.`, - ); +const announced = new Set(); + +/** For tests, and for anything that legitimately re-resolves. */ +export function resetHostAnnouncements(): void { + announced.clear(); } -function unknownEnvironment(environment: string, suffixedVar: string): AskError { - return new AskError( - `environment '${environment}' has no built-in BrowserStack AI host. Known ` + - `environments: ${KNOWN_ENVIRONMENTS}. Set ${suffixedVar} to add one.`, - ); +function announce(what: string, url: string, source: "env" | "default"): void { + const line = `${what}|${url}|${source}`; + if (announced.has(line)) return; + announced.add(line); + logger.info("askBrowserstackAI: %s is %s (source: %s)", what, url, source); } /** * Resolve Atlas's base URL: * - * 1. ASK_BROWSERSTACK_ATLAS_URL explicit, environment-agnostic - * 2. ASK_BROWSERSTACK_ATLAS_URL_ this environment's host - * 3. the built-in map for - * 4. refuse, by name + * 1. ASK_BROWSERSTACK_ATLAS_URL explicit override + * 2. the built-in staging default (see the warning above) * - * The overrides stay ahead of the map so nothing that works today stops working. + * Matching every other tool here, which ships its host in the code and treats the env var as + * an override — `TM_BASE_URLS`, the instrumentation endpoint. There is no environment map and + * no selector: one default, one override. */ export function atlasBaseUrl(): string { const explicit = process.env.ASK_BROWSERSTACK_ATLAS_URL; - if (explicit && explicit.trim()) return trimUrl(explicit); - - const environment = selectedEnvironment(); - if (!environment) { - throw noEnvironment("ASK_BROWSERSTACK_ATLAS_URL"); - } - - const suffixedVar = `ASK_BROWSERSTACK_ATLAS_URL_${environment.toUpperCase()}`; - const suffixed = process.env[suffixedVar]; - if (suffixed && suffixed.trim()) return trimUrl(suffixed); - - const builtIn = ATLAS_HOSTS[environment.toLowerCase()]; - if (builtIn) return trimUrl(builtIn.agent); - - throw unknownEnvironment(environment, suffixedVar); + const url = explicit && explicit.trim() ? trimUrl(explicit) : DEFAULT_ATLAS_URL; + announce("Atlas", url, explicit && explicit.trim() ? "env" : "default"); + return url; } /** Resolved per call, never captured at construction. */ @@ -154,26 +115,12 @@ export function agentUrl(): string { * Where a central-OAuth JWT is minted (CONTRACT v1.2 §I, as amended by task 7). * * The shared `delegation.token` path is gone from Atlas, so a user-attested central JWT is - * now the only way in. This is the endpoint that issues one from a username and access key. - * - * Same four rungs as the host, and refusing rather than guessing for the same reason: a - * deployment pointing at preprod must not sign in against production's auth server. + * the only way in. Same two rungs as the host, and the same staging default. */ export function authTokenUrl(): string { const explicit = process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL; - if (explicit && explicit.trim()) return trimUrl(explicit); - - const environment = selectedEnvironment(); - if (!environment) { - throw noEnvironment("ASK_BROWSERSTACK_AUTH_TOKEN_URL"); - } - - const suffixedVar = `ASK_BROWSERSTACK_AUTH_TOKEN_URL_${environment.toUpperCase()}`; - const suffixed = process.env[suffixedVar]; - if (suffixed && suffixed.trim()) return trimUrl(suffixed); - - const builtIn = ATLAS_HOSTS[environment.toLowerCase()]; - if (builtIn) return trimUrl(builtIn.auth); - - throw unknownEnvironment(environment, suffixedVar); + const url = + explicit && explicit.trim() ? trimUrl(explicit) : DEFAULT_AUTH_TOKEN_URL; + announce("auth token endpoint", url, explicit && explicit.trim() ? "env" : "default"); + return url; } diff --git a/tests/tools/askBrowserstack.test.ts b/tests/tools/askBrowserstack.test.ts index bb142c0..999eb47 100644 --- a/tests/tools/askBrowserstack.test.ts +++ b/tests/tools/askBrowserstack.test.ts @@ -7,7 +7,13 @@ import { startCallbackListener, } from "../../src/tools/ask-browserstack/callback.js"; import { - ATLAS_HOSTS, AskError, atlasBaseUrl, authTokenUrl, + AskError, + DEFAULT_ATLAS_URL, + DEFAULT_AUTH_TOKEN_URL, + agentUrl, + atlasBaseUrl, + authTokenUrl, + resetHostAnnouncements, } from "../../src/tools/ask-browserstack/config.js"; import { AUTH_REJECTED_DETAIL, @@ -462,34 +468,6 @@ describe("parseAsk", () => { }); }); -describe("host resolution", () => { - const saved = { ...process.env }; - - afterEach(() => { - process.env = { ...saved }; - }); - - it("refuses by name rather than guessing a host", () => { - delete process.env.ASK_BROWSERSTACK_ATLAS_URL; - delete process.env.ASK_BROWSERSTACK_ENV; - delete process.env.CAPABILITY_REGISTRY_ENV; - expect(() => atlasBaseUrl()).toThrow(AskError); - expect(() => atlasBaseUrl()).toThrow(/ASK_BROWSERSTACK_ATLAS_URL/); - }); - - it("lets the named environment pick the host, so preprod cannot fall back to prod", () => { - delete process.env.ASK_BROWSERSTACK_ATLAS_URL; - process.env.ASK_BROWSERSTACK_ENV = "preprod"; - process.env.ASK_BROWSERSTACK_ATLAS_URL_PREPROD = "https://atlas-preprod.example/"; - expect(atlasBaseUrl()).toBe("https://atlas-preprod.example"); - }); - - it("lets an explicit override win", () => { - process.env.ASK_BROWSERSTACK_ENV = "preprod"; - process.env.ASK_BROWSERSTACK_ATLAS_URL = "https://atlas.example/"; - expect(atlasBaseUrl()).toBe("https://atlas.example"); - }); -}); describe("the verified /agent response shape — CONTRACT v1.1 §B", () => { it("reads an ABSENT needs_approval as empty, because public() omits it when empty", () => { @@ -702,35 +680,6 @@ describe("POST /agent headers — CONTRACT v1.2 §4", () => { }); }); -describe("where we sign in", () => { - const saved = { ...process.env }; - - afterEach(() => { - process.env = { ...saved }; - }); - - it("refuses by name rather than guessing an auth host", () => { - delete process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL; - delete process.env.ASK_BROWSERSTACK_ENV; - delete process.env.CAPABILITY_REGISTRY_ENV; - expect(() => authTokenUrl()).toThrow(AskError); - expect(() => authTokenUrl()).toThrow(/ASK_BROWSERSTACK_AUTH_TOKEN_URL/); - }); - - it("lets the environment pick it, so preprod cannot sign in against production", () => { - delete process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL; - process.env.ASK_BROWSERSTACK_ENV = "preprod"; - process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL_PREPROD = " https://auth-pp.example/t "; - expect(authTokenUrl()).toBe("https://auth-pp.example/t"); - }); - - it("lets an explicit endpoint win", () => { - process.env.ASK_BROWSERSTACK_ENV = "preprod"; - process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL_PREPROD = "https://auth-pp.example/t"; - process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL = "https://auth.example/t"; - expect(authTokenUrl()).toBe("https://auth.example/t"); - }); -}); describe("minting a central JWT", () => { const URL_ = "https://auth.example/oauth2/v2/token"; @@ -1220,110 +1169,119 @@ describe("a 2xx carrying no delegation result is internally consistent (N4)", () }); }); -describe("the built-in Atlas host map", () => { +describe("host resolution — one hardcoded staging default, one override", () => { const saved = { ...process.env }; beforeEach(() => { - // Nothing but the selector: the whole point is that an install needs no URL. delete process.env.ASK_BROWSERSTACK_ATLAS_URL; delete process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL; - delete process.env.ASK_BROWSERSTACK_ENV; - delete process.env.CAPABILITY_REGISTRY_ENV; + resetHostAnnouncements(); }); afterEach(() => { process.env = { ...saved }; + resetHostAnnouncements(); }); - it.each([ - ["prod", "https://workflows.browserstack.com", "https://auth.browserstack.com/oauth2/v2/token"], - ["preprod", "https://ai-platform-service-preprod.bsstag.com", "https://auth-preprod.bsstag.com/oauth2/v2/token"], - ["stag", "https://ai-platform-service.bsstag.com", "https://auth-preprod.bsstag.com/oauth2/v2/token"], - ])("resolves %s from the selector alone", (env, agent, auth) => { - process.env.ASK_BROWSERSTACK_ENV = env; - expect(atlasBaseUrl()).toBe(agent); - expect(authTokenUrl()).toBe(auth); + it("with NO env vars, resolves the staging pair — exact literals", () => { + // Asserted literally so repointing to production has to be a deliberate test change + // rather than something that slips through. See TEMPORARY-STAGING-DEFAULT. + expect(atlasBaseUrl()).toBe("https://ai-platform-service.bsstag.com"); + expect(agentUrl()).toBe("https://ai-platform-service.bsstag.com/agent"); + expect(authTokenUrl()).toBe("https://auth-preprod.bsstag.com/oauth2/v2/token"); + expect(DEFAULT_ATLAS_URL).toBe("https://ai-platform-service.bsstag.com"); + expect(DEFAULT_AUTH_TOKEN_URL).toBe("https://auth-preprod.bsstag.com/oauth2/v2/token"); }); - it("sends staging at auth-preprod on purpose, not by copy-paste", () => { - // Staging's own issuer is auth-rengg-reg-ai-agent-dev, but preprod is configured as an - // extra environment and `_accepted_configs` returns the default PLUS extras, so a - // preprod-minted token validates there. Confirmed live (matched=scope). - expect(ATLAS_HOSTS.stag.auth).toBe(ATLAS_HOSTS.preprod.auth); - expect(ATLAS_HOSTS.stag.agent).not.toBe(ATLAS_HOSTS.preprod.agent); + it("never refuses for want of configuration — an install needs no env var", () => { + expect(() => atlasBaseUrl()).not.toThrow(); + expect(() => authTokenUrl()).not.toThrow(); }); - it("takes the selector from CAPABILITY_REGISTRY_ENV too, without a third variable", () => { - process.env.CAPABILITY_REGISTRY_ENV = "preprod"; - expect(atlasBaseUrl()).toBe("https://ai-platform-service-preprod.bsstag.com"); + it("lets the explicit override win, for both", () => { + process.env.ASK_BROWSERSTACK_ATLAS_URL = "https://atlas.example"; + process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL = "https://auth.example/t"; + expect(atlasBaseUrl()).toBe("https://atlas.example"); + expect(authTokenUrl()).toBe("https://auth.example/t"); }); - it("is case-insensitive about the environment name", () => { - process.env.ASK_BROWSERSTACK_ENV = "PreProd"; - expect(atlasBaseUrl()).toBe("https://ai-platform-service-preprod.bsstag.com"); + it("ignores a blank override rather than resolving to an empty host", () => { + process.env.ASK_BROWSERSTACK_ATLAS_URL = " "; + expect(atlasBaseUrl()).toBe(DEFAULT_ATLAS_URL); }); - it("lets an explicit override beat the map", () => { - process.env.ASK_BROWSERSTACK_ENV = "prod"; - process.env.ASK_BROWSERSTACK_ATLAS_URL = "https://atlas.example/"; - process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL = "https://auth.example/t/"; + it("strips trailing slashes, and the defaults carry none", () => { + process.env.ASK_BROWSERSTACK_ATLAS_URL = "https://atlas.example///"; + // Otherwise `${base}/agent` becomes `//agent`. expect(atlasBaseUrl()).toBe("https://atlas.example"); - expect(authTokenUrl()).toBe("https://auth.example/t"); + expect(agentUrl()).toBe("https://atlas.example/agent"); + expect(DEFAULT_ATLAS_URL.endsWith("/")).toBe(false); + expect(DEFAULT_AUTH_TOKEN_URL.endsWith("/")).toBe(false); }); - it("lets an env-suffixed override beat the map", () => { + it("takes no notice of an environment selector any more", () => { + // The map and ASK_BROWSERSTACK_ENV are gone; a stale selector must not change anything. process.env.ASK_BROWSERSTACK_ENV = "prod"; - process.env.ASK_BROWSERSTACK_ATLAS_URL_PROD = "https://atlas-pinned.example"; - process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL_PROD = "https://auth-pinned.example/t"; - expect(atlasBaseUrl()).toBe("https://atlas-pinned.example"); - expect(authTokenUrl()).toBe("https://auth-pinned.example/t"); + process.env.CAPABILITY_REGISTRY_ENV = "prod"; + process.env.ASK_BROWSERSTACK_ATLAS_URL_PROD = "https://should-be-ignored.example"; + expect(atlasBaseUrl()).toBe(DEFAULT_ATLAS_URL); + expect(authTokenUrl()).toBe(DEFAULT_AUTH_TOKEN_URL); }); +}); - it("strips trailing slashes from overrides, since the map entries carry none", () => { - process.env.ASK_BROWSERSTACK_ENV = "prod"; - process.env.ASK_BROWSERSTACK_ATLAS_URL = "https://atlas.example///"; - // Otherwise `${base}/agent` becomes `//agent`. - expect(atlasBaseUrl()).toBe("https://atlas.example"); - for (const host of Object.values(ATLAS_HOSTS)) { - expect(host.agent.endsWith("/")).toBe(false); - expect(host.auth.endsWith("/")).toBe(false); - } +describe("the resolved host is announced, so a wrong deployment is visible", () => { + const saved = { ...process.env }; + let lines: string[]; + + beforeEach(async () => { + delete process.env.ASK_BROWSERSTACK_ATLAS_URL; + resetHostAnnouncements(); + lines = []; + const { setLogger } = await import("../../src/logger.js"); + const capture = (...args: unknown[]) => lines.push(args.map(String).join(" ")); + setLogger({ info: capture, warn: capture, error: capture, debug: capture, flush: () => {} }); }); - it("refuses an environment the map has never heard of, by name", () => { - process.env.ASK_BROWSERSTACK_ENV = "dev"; - expect(() => atlasBaseUrl()).toThrow(AskError); - expect(() => atlasBaseUrl()).toThrow(/environment 'dev' has no built-in/); - // ...and names both the known set and the way to add one. - expect(() => atlasBaseUrl()).toThrow(/prod, preprod, stag/); - expect(() => atlasBaseUrl()).toThrow(/ASK_BROWSERSTACK_ATLAS_URL_DEV/); - expect(() => authTokenUrl()).toThrow(/ASK_BROWSERSTACK_AUTH_TOKEN_URL_DEV/); - }); - - it("REFUSES when no environment is selected — it does not default to production", () => { - // The decision, asserted deliberately so it cannot drift. An unconfigured install - // quietly writing to production Atlas is the failure the capability registry's own - // comment warns about, and this tool changes data. - expect(() => atlasBaseUrl()).toThrow(AskError); - expect(() => atlasBaseUrl()).toThrow(/no BrowserStack AI environment is selected/); - expect(() => atlasBaseUrl()).toThrow(/will not guess at production/); - expect(() => authTokenUrl()).toThrow(/no BrowserStack AI environment is selected/); - - // Specifically: NOT the prod host, even though the map contains one. - let resolved: string | undefined; - try { - resolved = atlasBaseUrl(); - } catch { - resolved = undefined; - } - expect(resolved).toBeUndefined(); + afterEach(async () => { + process.env = { ...saved }; + resetHostAnnouncements(); + const { setLogger } = await import("../../src/logger.js"); + const { pino } = await import("pino"); + setLogger(pino({ level: "silent" })); + }); + + it("names the default as the source when nothing is configured", () => { + atlasBaseUrl(); + authTokenUrl(); + // The logger is printf-style, so the captured args arrive alongside the format string. + const everything = lines.join("\n"); + expect(everything).toContain("source:"); + expect(everything).toContain("Atlas https://ai-platform-service.bsstag.com default"); + expect(everything).toContain( + "auth token endpoint https://auth-preprod.bsstag.com/oauth2/v2/token default", + ); }); - it("still works with only an explicit URL and no selector at all", () => { - // The pre-existing escape hatch keeps working for anyone already using it. + it("names the env var as the source when one is set", () => { process.env.ASK_BROWSERSTACK_ATLAS_URL = "https://atlas.example"; - process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL = "https://auth.example/t"; - expect(atlasBaseUrl()).toBe("https://atlas.example"); - expect(authTokenUrl()).toBe("https://auth.example/t"); + atlasBaseUrl(); + expect(lines.join("\n")).toContain("Atlas https://atlas.example env"); + expect(lines.join("\n")).not.toContain("default"); + }); + + it("announces once, not on every call, and never carries a credential", () => { + for (let i = 0; i < 5; i += 1) atlasBaseUrl(); + expect(lines.filter((l) => l.includes("Atlas"))).toHaveLength(1); + const everything = lines.join("\n"); + expect(everything).not.toContain("SECRET"); + expect(everything).not.toContain("access_key"); + expect(everything).not.toMatch(/Bearer/); + }); + + it("announces again when the host actually changes", () => { + atlasBaseUrl(); + process.env.ASK_BROWSERSTACK_ATLAS_URL = "https://atlas.example"; + atlasBaseUrl(); + expect(lines.filter((l) => l.includes("Atlas"))).toHaveLength(2); }); }); diff --git a/tests/tools/askBrowserstackE2E.test.ts b/tests/tools/askBrowserstackE2E.test.ts index ea289db..c8db125 100644 --- a/tests/tools/askBrowserstackE2E.test.ts +++ b/tests/tools/askBrowserstackE2E.test.ts @@ -696,17 +696,30 @@ describe("askBrowserstackAI, end to end through the server factory", () => { expect(Object.keys(body).sort()).toEqual(["product", "task"]); }); - it("refuses by name when there is nowhere to sign in", async () => { + it("signs in against the built-in staging endpoint when nothing is configured", async () => { + // No refusal any more: the hosts ship with the tool, so an install needs no env var. delete process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL; - const fetchSpy = vi.fn(); - vi.stubGlobal("fetch", fetchSpy); + delete process.env.ASK_BROWSERSTACK_ATLAS_URL; + const seen: string[] = []; + vi.stubGlobal("fetch", async (url: string) => { + seen.push(String(url)); + return { + status: 200, + headers: { get: () => "application/json" }, + json: async () => (String(url).includes("oauth2") + ? { access_token: MINTED, expires_in: 3600 } + : { ok: true, status: "ok", answer: "" }), + }; + }); const server = await buildServer(); - fakeClient(server.getInstance(), { elicitation: {} }, []); + fakeClient(server.getInstance(), { roots: {} }, []); - const { result, payload } = await call(server.getTools()); - expect(result.isError).toBe(true); - expect(payload.error).toMatch(/ASK_BROWSERSTACK_AUTH_TOKEN_URL/); - expect(fetchSpy).not.toHaveBeenCalled(); + const { payload } = await call(server.getTools()); + expect(payload.status).toBe("ok"); + expect(seen).toEqual([ + "https://auth-preprod.bsstag.com/oauth2/v2/token", + "https://ai-platform-service.bsstag.com/agent", + ]); }); it("mints the token with the exact client_credentials grant", async () => { @@ -918,25 +931,23 @@ describe("askBrowserstackAI, end to end through the server factory", () => { expect(payload.approvals[0]).toMatchObject({ decision: "deny", reason: "declined" }); }); - it("signs in against the named environment, so preprod cannot use prod's auth", async () => { - process.env.ASK_BROWSERSTACK_ENV = "preprod"; - process.env.ASK_BROWSERSTACK_ATLAS_URL_PREPROD = "https://atlas-preprod.example"; - process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL_PREPROD = AUTH_URL; - delete process.env.ASK_BROWSERSTACK_ATLAS_URL; - delete process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL; + it("an environment selector no longer changes anything", async () => { + // The map and ASK_BROWSERSTACK_ENV are gone; a leftover selector must be inert rather + // than quietly repointing the tool. + process.env.ASK_BROWSERSTACK_ENV = "prod"; + process.env.ASK_BROWSERSTACK_ATLAS_URL_PROD = "https://should-be-ignored.example"; try { const server = await buildServer(); fakeClient(server.getInstance(), { roots: {} }, []); const stub = atlas({}); await call(server.getTools()); - expect(stub.calls[0].url).toBe("https://atlas-preprod.example/agent"); + // Still the explicit override this suite sets, never the selector's host. + expect(stub.calls[0].url).toBe("https://atlas.example/agent"); expect(stub.calls[0].headers.Authorization).toBe(`Bearer ${MINTED}`); - expect(stub.mints).toHaveLength(1); } finally { delete process.env.ASK_BROWSERSTACK_ENV; - delete process.env.ASK_BROWSERSTACK_ATLAS_URL_PREPROD; - delete process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL_PREPROD; + delete process.env.ASK_BROWSERSTACK_ATLAS_URL_PROD; } }); }); @@ -1092,17 +1103,26 @@ describe("askBrowserstackAI, end to end through the server factory", () => { }); }); - it("refuses by name when no host is configured, without calling anything", async () => { + it("falls back to the built-in staging host when no override is set", async () => { delete process.env.ASK_BROWSERSTACK_ATLAS_URL; - const fetchSpy = vi.fn(); - vi.stubGlobal("fetch", fetchSpy); + const seen: string[] = []; + vi.stubGlobal("fetch", async (url: string) => { + seen.push(String(url)); + return { + status: 200, + headers: { get: () => "application/json" }, + json: async () => (String(url).includes("oauth2") + ? { access_token: MINTED, expires_in: 3600 } + : { ok: true, status: "ok", answer: "" }), + }; + }); const server = await buildServer(); - fakeClient(server.getInstance(), { elicitation: {} }, []); + fakeClient(server.getInstance(), { roots: {} }, []); - const { result, payload } = await call(server.getTools()); - expect(result.isError).toBe(true); - expect(payload.error).toMatch(/ASK_BROWSERSTACK_ATLAS_URL/); - expect(fetchSpy).not.toHaveBeenCalled(); + const { result } = await call(server.getTools()); + expect(result.isError).toBeUndefined(); + // TEMPORARY-STAGING-DEFAULT: asserted literally so repointing must be deliberate. + expect(seen).toContain("https://ai-platform-service.bsstag.com/agent"); }); }); From 2de13db6801a4d4aef689c3afbaa3d217fd7e839 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Wed, 26 Aug 2026 12:08:42 +0530 Subject: [PATCH 20/31] Tell the user when AI is not enabled for their account MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Atlas is adding a per-product entitlement gate to the delegation route, the one the WebSocket path already had, and it answers 403 when an account is not on the product's agent flag. That gate fails open on its side — Redis down, a flag never seeded, an unknown product all allow the request — so a 403 is a deliberate "this account is not enabled" and never an outage, which is what makes it worth a sentence of its own. Until now it would have arrived as a generic pre-run refusal quoting Atlas's bare detail: accurate and useless. It is now its own outcome, kept apart from the four authentication failures because the fix is different from all of them. It is not a rejected credential, and saying so explicitly matters — the credentials authenticated fine, so anyone reading a vaguer message would go and rotate a working access key. It is not a permission denial either: nobody declined anything and the relay is irrelevant, so it takes precedence over both the never-reached and remote-mode readings, which are technically true of a 403 and tell the reader nothing they can act on. The message names the product, because the flags are per product and an account entitled for one is not necessarily entitled for another. A bare "not enabled" sends someone to their admin asking about the wrong thing. Classification keys on the status, not on the sentence in the body, and says so where someone might be tempted otherwise. Matching the prose would break silently the first time anyone rewords it, and silently means falling back to the generic error this change exists to replace. Atlas may add a structural code to that body; the place to prefer it is marked, with the status left as the fallback for an older Atlas. --- src/tools/ask-browserstack/register.ts | 7 +- src/tools/ask-browserstack/relay.ts | 67 +++++++++++++++- tests/tools/askBrowserstack.test.ts | 103 ++++++++++++++++++++++++- tests/tools/askBrowserstackE2E.test.ts | 45 +++++++++++ 4 files changed, 218 insertions(+), 4 deletions(-) diff --git a/src/tools/ask-browserstack/register.ts b/src/tools/ask-browserstack/register.ts index feff117..cb5d12d 100644 --- a/src/tools/ask-browserstack/register.ts +++ b/src/tools/ask-browserstack/register.ts @@ -315,7 +315,12 @@ export function addAskBrowserstackAITool( ); } - return toResult(buildResult(await transport(url, headers, body), approvals, mode)); + return toResult( + // `product` reaches the result so an entitlement refusal can name it: the flags + // are per product, and a bare "not enabled" sends the user to their admin asking + // about the wrong thing. + buildResult(await transport(url, headers, body), approvals, mode, product), + ); } catch (error) { const message = error instanceof AskError || error instanceof Error diff --git a/src/tools/ask-browserstack/relay.ts b/src/tools/ask-browserstack/relay.ts index a15500d..559f98d 100644 --- a/src/tools/ask-browserstack/relay.ts +++ b/src/tools/ask-browserstack/relay.ts @@ -62,6 +62,15 @@ export const RELAY_OFF_DETAILS: Record = { "reason alone. Mid-run approval works when the server runs locally over stdio; retrying " + "against this deployment will keep failing the same way.", + // Not a refusal by anyone and not a relay problem at all: the account is not on the + // product's agent flag. The product-specific sentence and what to do about it live in + // `error`, so this one points there rather than duplicating the plumbing. + not_entitled: + "NOBODY DECLINED THIS AND NOTHING RAN. BrowserStack AI is not enabled for this account, " + + "so the request was refused before the agent started. `error` says which product and " + + "what to do about it. This is an entitlement on the account, not a problem with your " + + "credentials and not a decision anyone made about your request.", + // The request never got as far as the agent. Distinct from `disabled` (the agent ran, with // the relay switched off) and from a decline (someone was asked and said no), because the // three call for completely different things from whoever reads them. @@ -115,6 +124,43 @@ export function looksLikeDelegationResult(body: unknown): boolean { ); } +/** + * Is this Atlas saying the ACCOUNT is not enabled for the product's agent? + * + * Atlas gained an entitlement gate on `POST /agent` (`flags.is_agent_enabled`, AIC-386) that + * the WebSocket path already had. The flags are PER PRODUCT — `aiHarnessAgent` for tm, + * `aiHarnessAgentTRA`, `aiHarnessAgentA11y` — so an account can be entitled for one product + * and not another. It is also FAIL-OPEN on Atlas's side: Redis down, a flag never seeded, or + * an unknown product all allow the request. So a 403 here is a real, deliberate "this account + * is not enabled", never an outage. + * + * KEYED STRUCTURALLY, NEVER ON THE PROSE. Matching the sentence would break silently the + * first time someone rewords it, falling through to a generic error. + * + * TODO(atlas/9.md): Atlas is deciding whether to add a `code` field to this body. When it + * lands, prefer it over the status — add the check as the first rung here and leave the + * status as the fallback for an older Atlas. Until then the status IS the structural signal. + */ +export function isNotEntitled(response: AgentResponse): boolean { + return response.status === 403; +} + +/** + * The sentence the user asked for, with the product named. + * + * Entitlement is per product, so a bare "not enabled" sends someone to their admin asking + * about the wrong thing. The disambiguation from a 401 is deliberate and load-bearing: + * without it, a working access key gets rotated in response to a permissions problem. + */ +export const NOT_ENTITLED_DETAIL = (product: string): string => { + const scope = product && product.trim() ? ` for \`${product.trim()}\`` : ""; + return ( + `BrowserStack AI is not enabled${scope} on your account. Please contact your admin. ` + + `YOUR CREDENTIALS ARE FINE — they authenticated successfully; this is a per-product ` + + `entitlement on the account. Nothing was run and nobody declined anything.` + ); +}; + export function neverReachedAgent(response: AgentResponse): boolean { // No response at all: nothing could have run. if (response.status === 0) return true; @@ -376,7 +422,17 @@ function relayVerdict( payload: Record, mode: RelayMode, reachedAgent: boolean, + notEntitled: boolean, ): AskResult["permission_relay"] { + // FIRST, ahead of `not_reached`. Both are true of a 403 — the request certainly did not + // reach the agent — but only one of them tells the reader what to do about it. + if (notEntitled) { + return { + used: false, + reason: "not_entitled", + detail: RELAY_OFF_DETAILS.not_entitled, + }; + } // FIRST, because it outranks both of the cases below. If the request never reached the // agent then no channel was exercised whether or not one was offered, and saying the run // went read-only (`no_human`) would be just as wrong as saying it was used: nothing ran at @@ -414,6 +470,8 @@ export function buildResult( response: AgentResponse, approvals: ApprovalRecord[], mode: RelayMode, + /** Named in the not-entitled sentence, because entitlement is per product. */ + product = "", ): AskResult { const payload = asRecord(response.body); // ABSENT WHEN EMPTY, never `[]` (v1.1 §B): Atlas's `public()` omits the key entirely, as @@ -441,9 +499,9 @@ export function buildResult( elicitations: withOutcomes(approvals), needs_approval: needsApproval, applied_before_stop: readAppliedBeforeStop(payload), - permission_relay: relayVerdict(payload, mode, reachedAgent), + permission_relay: relayVerdict(payload, mode, reachedAgent, isNotEntitled(response)), atlas_response: response.body ?? null, - ...atlasError(response, payload), + ...atlasError(response, payload, product), }; } @@ -479,9 +537,14 @@ export const UNAUTHENTICATED_DETAIL = function atlasError( response: AgentResponse, payload: Record, + product: string, ): { error?: string } { if (response.status === 0 && response.error) return { error: response.error }; + // BEFORE Atlas's own string: for an entitlement refusal ours names the product and says + // what to do, where Atlas's is a bare `detail` a user cannot act on. + if (isNotEntitled(response)) return { error: NOT_ENTITLED_DETAIL(product) }; + // Atlas's own error string wherever it sent one — on a 502 that carries a full result, // this is the run explaining its own failure, and nothing here should talk over it. const reported = payload.error; diff --git a/tests/tools/askBrowserstack.test.ts b/tests/tools/askBrowserstack.test.ts index 999eb47..82b76a1 100644 --- a/tests/tools/askBrowserstack.test.ts +++ b/tests/tools/askBrowserstack.test.ts @@ -35,6 +35,7 @@ import { errorResult, deriveStatus, elicitationMessage, + isNotEntitled, looksLikeDelegationResult, neverReachedAgent, UNAUTHENTICATED_DETAIL, @@ -1113,7 +1114,8 @@ describe("a result body outranks the HTTP status (N1)", () => { }); it("still calls a bare {detail} refusal not_reached, whatever else changed", () => { - for (const status of [400, 401, 403, 503]) { + // 403 is deliberately NOT in this list any more — it has its own outcome, below. + for (const status of [400, 401, 502, 503]) { const result = buildResult({ status, body: { detail: "nope" } }, [], true); expect(neverReachedAgent({ status, body: { detail: "nope" } })).toBe(true); expect(result.permission_relay.used).toBe(false); @@ -1285,3 +1287,102 @@ describe("the resolved host is announced, so a wrong deployment is visible", () expect(lines.filter((l) => l.includes("Atlas"))).toHaveLength(2); }); }); + +describe("403 — the account is not entitled, which is none of the other failures", () => { + // The shape Atlas's entitlement gate returns; the sentence in it is deliberately not what + // we key on. + const REFUSED = { status: 403, body: { detail: "agent is not enabled for this account" } }; + + it("keys on the status, not the prose", () => { + expect(isNotEntitled(REFUSED)).toBe(true); + // A reworded body must classify identically — pattern-matching the sentence would fail + // silently into a generic error the first time someone edits it. + expect(isNotEntitled({ status: 403, body: { detail: "totally different wording" } })) + .toBe(true); + expect(isNotEntitled({ status: 403, body: null })).toBe(true); + expect(isNotEntitled({ status: 403, body: { ok: false, status: "error", answer: "" } })) + .toBe(true); + // ...and nothing else is an entitlement problem. + for (const status of [0, 200, 400, 401, 429, 500, 502, 503]) { + expect(isNotEntitled({ status, body: { detail: "agent is not enabled" } })).toBe(false); + } + }); + + it("gives the user the sentence they asked for, naming the product", () => { + const result = buildResult(REFUSED, [], "offered", "a11y"); + expect(result.error).toContain( + "BrowserStack AI is not enabled for `a11y` on your account. Please contact your admin.", + ); + }); + + it("drops the product clause rather than naming an empty one", () => { + expect(buildResult(REFUSED, [], "offered").error).toContain( + "BrowserStack AI is not enabled on your account. Please contact your admin.", + ); + expect(buildResult(REFUSED, [], "offered", " ").error).not.toContain("``"); + }); + + it("says outright that the credentials are fine, so a working key is not rotated", () => { + const result = buildResult(REFUSED, [], "offered", "tm"); + expect(result.error).toMatch(/YOUR CREDENTIALS ARE FINE/); + expect(result.error).toMatch(/per-product entitlement/); + }); + + it("is its own reason, not not_reached and not a denial", () => { + const result = buildResult(REFUSED, [], "offered", "tm"); + expect(result.permission_relay.used).toBe(false); + expect(result.permission_relay.reason).toBe("not_entitled"); + expect(result.permission_relay.detail).toMatch(/NOBODY DECLINED THIS AND NOTHING RAN/); + // The relay vocabulary stays clean. + expect(result.permission_relay.reason).not.toBe("not_reached"); + expect(result.approvals).toEqual([]); + expect(result.elicitations).toEqual([]); + }); + + it("beats not_reached and remote_mode, which are both also true of a 403", () => { + expect(buildResult(REFUSED, [], "remote_mode", "tm").permission_relay.reason) + .toBe("not_entitled"); + expect(buildResult(REFUSED, [], "no_human", "tm").permission_relay.reason) + .toBe("not_entitled"); + }); + + it("leaves applied_before_stop null: no gate ran", () => { + const result = buildResult(REFUSED, [], "offered", "tm"); + expect(result.applied_before_stop).toBeNull(); + expect(result.status).toBe("error"); + expect(result.ok).toBe(false); + }); + + it("is a FIFTH distinct thing, not any of the four auth failures", () => { + const entitlement = buildResult(REFUSED, [], "offered", "tm").error!; + // /agent's 401 is "we signed in fine, Atlas refused the token" — a server + // misconfiguration. A 403 is neither that nor a credential problem. + const atlasRefusedToken = buildResult( + { status: 401, body: { detail: "unauthorized" } }, [], "offered", "tm", + ).error!; + + const all = [ + entitlement, + atlasRefusedToken, + AUTH_SCOPE_REFUSED_DETAIL(400), + AUTH_REJECTED_DETAIL(401), + AUTH_UNREACHABLE_DETAIL, + ]; + expect(new Set(all).size).toBe(5); + + // The entitlement one sends the reader to an admin, and nowhere near a credential. + expect(entitlement).toMatch(/contact your admin/); + expect(entitlement).not.toMatch(/rejected/); + expect(entitlement).not.toMatch(/ASK_BROWSERSTACK/); + expect(entitlement).not.toMatch(/required_scope/); + expect(atlasRefusedToken).toMatch(/required_scope/); + }); + + it("prefers our actionable sentence over Atlas's bare detail", () => { + const withError = buildResult( + { status: 403, body: { detail: "nope", error: "forbidden" } }, [], "offered", "tm", + ); + expect(withError.error).toMatch(/Please contact your admin/); + expect(withError.error).not.toBe("forbidden"); + }); +}); diff --git a/tests/tools/askBrowserstackE2E.test.ts b/tests/tools/askBrowserstackE2E.test.ts index c8db125..211a61f 100644 --- a/tests/tools/askBrowserstackE2E.test.ts +++ b/tests/tools/askBrowserstackE2E.test.ts @@ -887,6 +887,51 @@ describe("askBrowserstackAI, end to end through the server factory", () => { expect(payload.approvals).toEqual([]); }); + it("reads a 403 as 'AI is not enabled for your account', naming the product", async () => { + const server = await buildServer(); + const elicit = fakeClient(server.getInstance(), { elicitation: {} }, []); + vi.stubGlobal("fetch", withAuth(async () => ({ + status: 403, + headers: { get: () => "application/json" }, + json: async () => ({ detail: "agent is not enabled for this account" }), + }))); + + const { result, payload } = await call(server.getTools(), { + product: "a11y", query: "scan the site", + }); + + expect(payload.error).toContain( + "BrowserStack AI is not enabled for `a11y` on your account. Please contact your admin.", + ); + expect(payload.error).toMatch(/YOUR CREDENTIALS ARE FINE/); + expect(payload.permission_relay).toEqual({ + used: false, + reason: "not_entitled", + detail: expect.stringContaining("NOBODY DECLINED THIS AND NOTHING RAN"), + }); + // Nothing ran, nobody was asked, and no gate reported anything. + expect(elicit).not.toHaveBeenCalled(); + expect(payload.approvals).toEqual([]); + expect(payload.applied_before_stop).toBeNull(); + expect(payload.status).toBe("error"); + expect(result.isError).toBe(true); + }); + + it("classifies a 403 the same way when Atlas rewords the body", async () => { + // Keyed on the status, never the prose. + const server = await buildServer(); + fakeClient(server.getInstance(), { elicitation: {} }, []); + vi.stubGlobal("fetch", withAuth(async () => ({ + status: 403, + headers: { get: () => "application/json" }, + json: async () => ({ detail: "some entirely new sentence", code: "whatever" }), + }))); + + const { payload } = await call(server.getTools()); + expect(payload.permission_relay.reason).toBe("not_entitled"); + expect(payload.error).toMatch(/Please contact your admin/); + }); + it.each([ [400, "task is required"], [503, "delegation is not enabled"], From 3ec33ca6f81aee5350cc850306a782102283be68 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Wed, 26 Aug 2026 16:11:55 +0530 Subject: [PATCH 21/31] Take the capability registry out of this release MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit searchCapability, invokeEndpoint, listProducts, listEntities and describeEntity are a different piece of work — Approach 2, driven by a prebuilt index — that this branch inherited by being cut from feat/capability-registry. They are not part of what is shipping here, and a release carrying five extra tools nobody asked for is a release nobody can reason about, so they come out. Nothing is lost. Those five commits are untouched on feat/capability-registry, locally and on ctoi, so this removes them from one branch rather than from the world; restoring them is a merge, not an archaeology exercise. The coupling turned out to be a single import in the server factory: the relay stopped importing the registry's auth header when /agent moved to a central JWT, and nothing else ever reached across. Also dropped: the 205KB capability-index.json artifact and its entry in the published files list, since neither has a consumer here any more, and the citation in tm-base-url that pointed at a naming precedent no longer in this tree — a comment referring to code that is not there is worse than no comment. --- capability-index.json | 1 - package.json | 3 +- src/lib/tm-base-url.ts | 4 +- src/server-factory.ts | 5 - src/tools/capability-registry/bind.ts | 152 ---------- src/tools/capability-registry/config.ts | 138 --------- src/tools/capability-registry/egress.ts | 87 ------ src/tools/capability-registry/index-loader.ts | 88 ------ src/tools/capability-registry/register.ts | 271 ------------------ src/tools/capability-registry/resolve.ts | 80 ------ src/tools/capability-registry/search.ts | 154 ---------- src/tools/capability-registry/types.ts | 98 ------- tests/fixtures/registry-index.json | 1 - tests/tools/capabilityRegistry.test.ts | 240 ---------------- .../tools/capabilityRegistryArtifact.test.ts | 67 ----- tests/tools/capabilityRegistryE2E.test.ts | 138 --------- tests/tools/capabilityRegistryRegion.test.ts | 161 ----------- 17 files changed, 3 insertions(+), 1685 deletions(-) delete mode 100644 capability-index.json delete mode 100644 src/tools/capability-registry/bind.ts delete mode 100644 src/tools/capability-registry/config.ts delete mode 100644 src/tools/capability-registry/egress.ts delete mode 100644 src/tools/capability-registry/index-loader.ts delete mode 100644 src/tools/capability-registry/register.ts delete mode 100644 src/tools/capability-registry/resolve.ts delete mode 100644 src/tools/capability-registry/search.ts delete mode 100644 src/tools/capability-registry/types.ts delete mode 100644 tests/fixtures/registry-index.json delete mode 100644 tests/tools/capabilityRegistry.test.ts delete mode 100644 tests/tools/capabilityRegistryArtifact.test.ts delete mode 100644 tests/tools/capabilityRegistryE2E.test.ts delete mode 100644 tests/tools/capabilityRegistryRegion.test.ts diff --git a/capability-index.json b/capability-index.json deleted file mode 100644 index 1c30ff6..0000000 --- a/capability-index.json +++ /dev/null @@ -1 +0,0 @@ -{"schema_version":1,"build_id":"8084a81bd6-173caps-b381220","harness_commit":"b3812207","products":{"tm":{"summary":"BrowserStack Test Management API (v1 \u2014 the SSO/OAuth app API, cookie-authenticated).","capabilities":[{"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"}],"body":[{"name":"owner","type":"integer","description":"The ID of the user to assign as the owner of the test run."}],"intent":"Assign a owner to the test run","guidance":["Assign a user as the owner of a specific test run within a project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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, verified live: 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":["Archive multiple test cases within a project","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"paginated":true},{"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,"description":"Plan INTEGER ids."}],"intent":"Archive test plans in bulk (reversible \u2014 prefer this over delete)","guidance":["REVERSIBLY remove plans from view","Async above the bulk threshold","Soft-archive one or more plans","reach for it whenever the user wants plans \"removed\" or \"cleaned up\" without saying they must be destroyed"],"returns":["async","unique_id"]},{"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"}],"query":[{"name":"include_subfolders_tc","type":"boolean"}],"body":[{"name":"assignee_id","type":"integer","example":2,"description":"ID of the assignee to whom the test cases will be assigned"},{"name":"mapping_ids","type":"array","example":[999,991,1024,1019],"description":"List of test case IDs to be linked"},{"name":"select_all","type":"boolean","example":false,"description":"Flag to select all test cases"},{"name":"de_selected_ids","type":"array","description":"List of test case IDs to be de-selected"},{"name":"folder_id","type":"integer","description":"ID of the folder to which the test cases belong"}],"intent":"Bulk assign test cases to a test run","guidance":["assign specific cases within a run to a user (async above 300)","Assign multiple test cases to a specific test run within a project, allowing for bulk operations on test case assignments"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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` \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, verified live: 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":"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,"json_path":"/test_case/ids"},{"name":"de_selected_ids","type":"array","required":true,"json_path":"/test_case/de_selected_ids"},{"name":"select_all","type":"boolean","required":true,"json_path":"/test_case/select_all"},{"name":"folder_id","type":"string","description":"SOURCE folder that scopes the selection, at top level. OPTIONAL when you pass explicit `ids` (verified live: the copy succeeds without it), but send it whenever `select_all` is true \u2014 it is what bounds the set, and `select_all` with `folder_id` and no `q` copies exactly that folder's cases. Integer and string are both accepted; the declared string matches what the UI sends here, which unlike bulk-"},{"name":"destination_folder_id","type":"integer","required":true,"description":"Target folder for the copies, and the one genuinely required destination field \u2014 omitting it is a bare `400`, verified live. NOTE it is TOP-LEVEL for copy, unlike bulk-move where the destination sits inside `test_case`."},{"name":"destination_project_id","type":"string","description":"Target project id. Needed only for a copy to ANOTHER project. For a copy WITHIN one project it is OPTIONAL \u2014 verified live, omitting it copies correctly into `destination_folder_id`, and integer and string are both accepted. The product does both: its Copy/Move modal sends the source project id, its clone / drag-and-drop path omits it. Send the source id to be explicit or omit it; never invent a v"}],"intent":"Bulk copy test cases","guidance":["copy a selection to a folder (async above 30)","Bulk copy test cases from one folder to another within a project","This operation allows you to copy multiple test cases at once, which can be useful for organizing or duplicating test cases across different folders","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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` \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, verified live: 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 delete.","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":"Delete every case in scope, minus `de_selected_ids`.","json_path":"/test_case/select_all"}],"intent":"Bulk Delete Test Cases from Search","guidance":["All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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."}],"intent":"Delete test results in bulk \u2014 PARTIAL SUCCESS is normal, always read `skipped`","guidance":["Authorisation is enforced per id (a caller must be the result's author or a project admin)","treating a 200 as \"all deleted\" will silently misreport","result_ids must be a non-empty array (400 otherwise) of at most 300 ids (422 beyond that)","batch larger sets yourself"],"returns":["id","reason"]},{"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` \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, verified live: 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 edit.","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":"automation_status","type":"string","json_path":"/test_case/automation_status"},{"name":"case_type","type":"integer","json_path":"/test_case/case_type"},{"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"},{"name":"owner","type":"integer","description":"TM user id.","json_path":"/test_case/owner"},{"name":"preconditions","type":"string","json_path":"/test_case/preconditions"},{"name":"priority","type":"integer","json_path":"/test_case/priority"},{"name":"status","type":"integer","json_path":"/test_case/status"},{"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"}],"intent":"Bulk Update Test Cases","guidance":["bare values, REPLACE-only (async above 100)","Update multiple test cases within a project","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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"}],"query":[{"name":"include_subfolders_tc","type":"boolean"},{"name":"status_id","type":"integer"}],"body":[{"name":"attachments","type":"array","example":[]},{"name":"de_selected_ids","type":"array","example":[]},{"name":"description","type":"string","example":"

something

","description":"HTML formatted comment or note"},{"name":"folder_id","type":"integer","example":21},{"name":"issues","type":"array","example":[]},{"name":"mapping_ids","type":"array","example":[999,991,1024,1019]},{"name":"select_all","type":"boolean","example":false},{"name":"status_id","type":"integer","example":21},{"name":"test_run_ids","type":"array","example":[1001,1002,1003],"description":"Array of BTCER IDs to apply the bulk edit to (passed when automation = true)"},{"name":"test_case_counts","type":"array","example":[1,3,2],"description":"Array of test case counts corresponding to each BTCER ID in test_run_ids (passed when automation = true)"}],"intent":"Bulk edit test cases result in test run","guidance":["Update multiple test cases in a specific test run within a project, allowing for bulk operations such as changing status, adding attachments, and more"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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, verified live: 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},{"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},{"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`.\n\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},{"name":"value","type":"string"}],"json_path":"/test_case/priority"},{"name":"status","type":"string","fields":[{"name":"operation","type":"string","required":true},{"name":"value","type":"string"}],"json_path":"/test_case/status"},{"name":"case_type","type":"string","fields":[{"name":"operation","type":"string","required":true},{"name":"value","type":"string"}],"json_path":"/test_case/case_type"},{"name":"automation_state","type":"string","fields":[{"name":"operation","type":"string","required":true},{"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},{"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},{"name":"value","type":"string"}],"json_path":"/test_case/owner"},{"name":"preconditions","type":"string","description":"Text value.","fields":[{"name":"operation","type":"string","required":true},{"name":"value","type":"string"}],"json_path":"/test_case/preconditions"},{"name":"review_status","type":"string","fields":[{"name":"operation","type":"string","required":true},{"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},{"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":["per-field { value, operation }","Correct for one case too (pass a single id)","Update many test cases in ONE call","Every field is an object of the form {\"value\": \u2026, \"operation\": \u2026}","so this is the only write path that can ADD or REMOVE from a collection without replacing it","one call for N cases instead of N calls"],"returns":["async","unique_id"],"paginated":true},{"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` \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, verified live: 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 move.","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":"Move every case in scope, minus `de_selected_ids`.","json_path":"/test_case/select_all"},{"name":"destination_folder_id","type":"integer","description":"Target folder id. Falls back to `folder_id` when omitted.","json_path":"/test_case/destination_folder_id"},{"name":"folder_id","type":"integer","description":"Legacy alias for `destination_folder_id`, used only when that is absent.","json_path":"/test_case/folder_id"},{"name":"destination_project_id","type":"integer","description":"Set only for a cross-project move. Shared cases cannot be moved across projects (422).","json_path":"/test_case/destination_project_id"}],"intent":"Bulk Move Test Cases","guidance":["Move multiple test cases from one folder to another within a project","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"paginated":true},{"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":"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, verified live: 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 Retrieve Test Cases","guidance":["Retrieve multiple test cases within a project based on specified criteria","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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,"description":"Plan INTEGER ids."}],"intent":"Un-archive test plans in bulk (undo bulk-archive)","guidance":["Restore previously archived plans"],"returns":["async","unique_id"]},{"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":"Clone an exploratory session (async when it has many logs).","guidance":["defects are the issues linked to the session","the parent project takes the INTEGER project id (v1)","List defaults to active sessions"]},{"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","description":"Name for the clone. Defaults to a server-generated copy name.","json_path":"/test_plan/name"},{"name":"copy_all_sub_plans","type":"boolean","json_path":"/test_plan/copy_all_sub_plans"},{"name":"sub_plan_ids","type":"array","description":"Clone only these sub-plans. Ignored when copy_all_sub_plans is true.","json_path":"/test_plan/sub_plan_ids"}],"intent":"Clone a test plan, optionally with its sub-plans","guidance":["copy_all_sub_plans or sub_plan_ids to bring its sub-plans along","Copy an existing plan","copy_all_sub_plans: true clones every sub-plan","alternatively pass sub_plan_ids to clone a specific subset","Omit both to clone just the plan itself"]},{"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"}],"body":[{"name":"copy_tc_assignee","type":"boolean","description":"Copy test case assignees from the original run"},{"name":"copy_issues","type":"boolean","description":"Copy issues linked to the original run"},{"name":"copy_tags","type":"boolean","description":"Copy tags from the original run"},{"name":"copy_all_cases","type":"boolean","description":"Include all test cases in the cloned run"},{"name":"status","type":"array","example":["passed","failed"],"description":"Status strings to filter test cases to copy"},{"name":"status_id","type":"array","example":[1,2],"description":"Status IDs to filter test cases to copy"},{"name":"name","type":"string","example":"Cloned Test Run - 2025","description":"Name for the new cloned test run"}],"intent":"Clone a test run","guidance":["Create a new test run by cloning an existing one, with options to copy test case assignees, issues, tags, and filter cases by status"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"Close an exploratory session","guidance":["Transitions an active exploratory session to the closed state"]},{"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"}],"intent":"Close a test run","guidance":["Close a specific test run within a project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"ID of folder to copy"},{"name":"destination_folder_id","type":"integer","required":true,"example":3,"description":"ID of destination parent folder"},{"name":"destination_project_id","type":"integer","required":true,"example":10000,"description":"Destination project ID (can be same or different)"},{"name":"async","type":"boolean","example":true,"description":"Whether to process asynchronously via background job"},{"name":"copy_test_cases","type":"boolean","example":true,"description":"Whether to copy test cases within folder"}],"intent":"Copy a folder to another location","guidance":["Copies a folder (and optionally its contents) to a destination folder within the same or different project","Supports both synchronous and asynchronous operations via the async flag","Async Processing: When async: true, the operation is queued as a Sidekiq background job and returns immediately with a unique tracking ID"],"returns":["async","folder_id","unique_id"]},{"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","example":false,"description":"Select every case in the project.","json_path":"/selection/select_all"},{"name":"unique_test_case_count","type":"integer","example":2,"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.","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`."},{"name":"configurations","type":"array","required":true,"example":[],"description":"Configuration ids. Required \u2014 pass an empty array if there are none."},{"name":"trtc_configuration_mappings","type":"array","description":"Per-configuration case mappings. An ARRAY of objects \u2014 the server rejects an object here.","fields":[{"name":"configuration_id","type":"integer"},{"name":"select_all","type":"boolean"},{"name":"linked_test_cases","type":"array"},{"name":"unlinked_test_cases","type":"array"}]}],"intent":"Count the test cases a selection resolves to (incl. configurations/datasets).","guidance":["The discovered ids must be CARRIED INTO the create body","discovery alone puts nothing in the run","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","It is validated before the selection","so a bare 400 'invalid data' means a bad test_run field"],"shape":"discovered"},{"method":"GET","path":"/api/v1/projects/{project_id}/test-plans/{test_plan_id}/test-runs/count","mode":"read","entity":"test_plan","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"test_plan_id","type":"integer","required":true}],"intent":"Count the test runs linked to a test plan","guidance":["how many runs a plan groups","cheaper and safer than paging the run list","Returns just the count of runs linked to the plan","Prefer this over paging the run list when the user only asked \"how many\"","list reads truncate around 30 KB and will make you under-count"],"shape":"discovered"},{"method":"POST","path":"/api/v1/projects/{project_id}/folder/{folder_id}/bulk-test-cases","mode":"write","entity":"folder","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"folder_id","type":"integer","required":true}],"body":[{"name":"test_cases","type":"array","required":true,"fields":[{"name":"name","type":"string","required":true},{"name":"test_case_folder_id","type":"string","required":true},{"name":"attachments","type":"array"},{"name":"automation_state","type":"integer"},{"name":"automation_status","type":"string"},{"name":"case_type","type":"integer"},{"name":"custom_fields","type":"object"},{"name":"description","type":"string"},{"name":"estimate","type":"string"},{"name":"estimated_duration_seconds","type":"integer"},{"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"},{"name":"reviewers","type":"array"},{"name":"send_for_review","type":"boolean"},{"name":"shared_precondition_id","type":"integer"},{"name":"status","type":"integer"},{"name":"tags","type":"array"},{"name":"template","type":"string"},{"name":"template_id","type":"integer"},{"name":"template_step_type","type":"string"},{"name":"test_case_dataset","type":"string"},{"name":"test_case_steps","type":"array"}]},{"name":"unique_id","type":"string","description":"Client-supplied idempotency key. When present and the group has the async bulk-create feature flag enabled, the request is processed asynchronously and acknowledged with 202; completion is delivered over the common WebSocket channel keyed by this id."}],"intent":"Create test cases in bulk for a folder (\u226410) \u2014 UNRELIABLE STATUS, see description.","guidance":["Creates several test cases in one request","more returns 400 \"More than permitted test cases sent\"","A 500 here does NOT mean nothing happened","The action is wrapped in a catch-all rescue that renders 500 {\"success\": false, \"message\": \"Failed to create test cases.\"} for any exception, including ones raised AFTER the cases were already inserted","Same status, same message, opposite outcomes","So on a 500: never retry blind"],"returns":["request_trace_id"]},{"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 new configuration for a project","guidance":["there is no flat, account-level list","Each configuration has an INTEGER id","the list returns a configurations[] array plus page info"],"returns":["id","name","created_at","group_id","is_suggested","record_status"]},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"body":[{"name":"option_value","type":"string","required":true,"description":"The option label."},{"name":"is_default","type":"boolean","required":true,"description":"REQUIRED \u2014 a missing value is a 400. Must be 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 parent option this hangs under."}],"intent":"Add one option to a dataset \u2014 how you add a dropdown value.","guidance":["option_value + is_default both required","Adds a single option","Both option_value and is_default are required","a missing is_default is 400 \"Invalid Params\"","For dropdown, at most one option may be the default"]},{"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}],"body":[{"name":"linked_projects","type":"array","description":"Project INTEGER ids this dataset applies to. This is what makes the field visible in a project \u2014 a dataset with no linked projects leaves the field invisible everywhere. NOTE the spelling differs from the project-mappings endpoint, which uses `link_projects` / `unlink_projects`."},{"name":"link_to_future_projects","type":"boolean","description":"Auto-link projects created later."},{"name":"linked_to_all_projects","type":"boolean","description":"Apply to every project in the workspace."},{"name":"options","type":"array","description":"The allowed values, for dropdown-style fields.","fields":[{"name":"option_value","type":"string","required":true},{"name":"is_default","type":"boolean","required":true},{"name":"parent_option_id","type":"integer"}]}],"intent":"Add a dataset (an option set + its project links) to an existing field.","guidance":["A dataset is how a field carries options and project scope","A field can hold more than one, letting different projects see different option sets for the same field","the key is linked_projects, and options is accepted at this level (it is a dataset body, not a field body)","for other types a dataset with linked_projects and no options is what scopes the field to projects"]},{"method":"POST","path":"/api/v1/admin-v2/settings/custom-fields","mode":"write","entity":"custom_field","body":[{"name":"field_name","type":"string","required":true},{"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 (e.g. `field_dropdown`) are a different API's and are rejected. Silently immutable after creation: an update that changes it returns 200 and changes nothing."},{"name":"entity_type","type":"string","values":["TestCase","TestResult","TestPlan","ExploratorySession"],"description":"Which entity the field hangs off. One field belongs to exactly one."},{"name":"is_required","type":"boolean","required":true,"description":"REQUIRED, and must be a literal boolean \u2014 omitting it is a 400."},{"name":"datasets","type":"array","required":true,"description":"REQUIRED for every field type, dropdown or not \u2014 omitting it is a 400. Carries the options and the project scope. Send one entry with `linked_projects` set.","fields":[{"name":"linked_projects","type":"array"},{"name":"link_to_future_projects","type":"boolean"},{"name":"linked_to_all_projects","type":"boolean"},{"name":"options","type":"array"}]},{"name":"place_holder_text","type":"string"},{"name":"default_value","type":"string","description":"Only honoured when `field_type` is `boolean`."},{"name":"levels","type":"array","description":"REQUIRED when field_type is `nested_dropdown`, minimum 2 entries, each {name, level_type}."}],"intent":"Create a custom field DEFINITION (workspace-admin action).","guidance":["options AND project scope both go in datasets[]","Creates the field definition","a new attribute available on the chosen entity type","configurable per workspace","so don't predict access","A caller without it gets 403"]},{"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,"description":"Name of the dataset.","json_path":"/dataset/name"},{"name":"variables","type":"array","required":true,"description":"List of dataset variables/columns (max 40).","fields":[{"name":"name","type":"string","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/variables"},{"name":"rows","type":"array","required":true,"description":"List of dataset rows (max 100).","fields":[{"name":"row_number","type":"integer","required":true},{"name":"row_data","type":"array","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/rows"}],"intent":"Create a new dataset.","guidance":["create a dataset with variables + rows","Create a new dataset with variables and rows for a project"],"returns":["name","created_at","rows_count","uuid"]},{"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.","json_path":"/exploratory_session/title"},{"name":"description","type":"string","example":"Explore edge cases in the checkout flow.","description":"Optional description.","json_path":"/exploratory_session/description"},{"name":"charter","type":"string","example":"Focus on payment error handling","description":"Testing charter describing the scope and goals.","json_path":"/exploratory_session/charter"},{"name":"timebox_duration","type":"integer","example":60,"description":"Planned duration in minutes.","json_path":"/exploratory_session/timebox_duration"},{"name":"owner","type":"integer","example":42,"description":"TCM user ID of the session owner / assignee.","json_path":"/exploratory_session/owner"},{"name":"assignee","type":"integer","example":42,"description":"Alias for `owner`. TCM user ID of the assignee.","json_path":"/exploratory_session/assignee"},{"name":"folder_id","type":"integer","example":456,"description":"ID of the folder to place this session in.","json_path":"/exploratory_session/folder_id"},{"name":"tags","type":"array","example":["regression","mobile"],"description":"Tags for the session.","json_path":"/exploratory_session/tags"},{"name":"configurations","type":"array","example":[1,3],"description":"Configuration IDs to associate with this session.","json_path":"/exploratory_session/configurations"},{"name":"attachments","type":"array","example":[101,102],"description":"Blob / media attachment IDs.","json_path":"/exploratory_session/attachments"},{"name":"issues","type":"array","description":"Issues to link to this session.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/exploratory_session/issues"}],"intent":"Create an exploratory session","guidance":["body wrapped in exploratory_session (title required)","Creates a new exploratory session within the specified project"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"]},{"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 button \u2014 spinner appeared but order was not created.

","description":"Rich-text HTML content of the log entry.","json_path":"/session_log/content"},{"name":"status","type":"string","values":["pass","fail","bug","retest","block","note"],"example":"fail","description":"Test result status for this log step.","json_path":"/session_log/status"},{"name":"elapsed_time","type":"integer","example":120,"description":"Time elapsed for this step in seconds.","json_path":"/session_log/elapsed_time"},{"name":"defects","type":"array","description":"Defects to link to this log entry.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/session_log/defects"}],"intent":"Create a session log entry","guidance":["add a log entry (rich-text HTML, sanitised on write)","Adds a new log entry to the specified exploratory session","Rich-text HTML content is sanitised before storage"],"returns":["id","status","content","created_at","elapsed_time","session_id","updated_at"]},{"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,"description":"Name of the filter"},{"name":"entity","type":"string","required":true,"values":["testcase","testplan","testrun","exploratory_session","test_plan_test_case"],"description":"Entity type the filter applies to. The server's validator accepts five values (the\nlast two were missing here); an unlisted value returns 400 \"Invalid JSON data\"."},{"name":"is_project_level","type":"boolean","description":"Whether this filter is available at project level"},{"name":"filters","type":"object","required":true,"description":"Filter criteria object containing the actual filter conditions"}],"intent":"Create a new filter","guidance":["save a filter view ({ name, entity, filters, is_project_level })","Create a new saved filter for test cases, test plans, or test runs in a project"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"]},{"method":"POST","path":"/api/v1/projects","mode":"write","entity":"project","body":[{"name":"name","type":"string","required":true,"json_path":"/project/name"},{"name":"description","type":"string","json_path":"/project/description"}],"intent":"Create a new project.","guidance":["Pinned to human approval","Create a new project with name and description"],"returns":["name","description"]},{"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},{"name":"report_type","type":"string","required":true,"values":["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","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_statu"},{"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 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`"},{"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;\n`custom_report` is a user-configured one. For a Requirement Traceability\nreport, `system_generated` makes the server auto-populate\n`report_filters.requirements` with every requirement in the project."},{"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"},{"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":"Integer run ids \u2014 pairs with report_timeframe=specific_test_runs.","json_path":"/report_filters/test_runs"},{"name":"test_plans","type":"array","description":"Integer plan ids \u2014 pairs with report_timeframe=specific_test_plans.","json_path":"/report_filters/test_plans"},{"name":"test_cases","type":"string","description":"Case selection \u2014 pairs with report_timeframe=specific_test_cases. FOLDER-KEYED\n(see SelectionData), never a flat id list: the server resolves the selection\nthrough its folder map, so any other shape yields zero cases and saves an\nUNSCOPED, project-wide report at 200.\n\nSend all three keys. Folder ids are STRING keys; test-case ids are INTEGERS\n(the numeric `id`, not the TC-NNN identifier) \u2014 take bo","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":"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.","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 } }.","json_path":"/report_filters/test_plan_selection"},{"name":"builds","type":"array","json_path":"/report_filters/builds"},{"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\n`Test Run Summary`, `Test Run Detailed Report` and `Test Case Activity` \u2014 on\nthe other six report types the server never reads it and drops it silently.\nPersisted (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`."},{"name":"users","type":"array","json_path":"/mail_to/users"},{"name":"external_mails","type":"array","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\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"},{"name":"time","type":"string","example":"08:00","description":"24-hour HH:MM, UTC.","json_path":"/frequency_details/time"},{"name":"day","type":"string","example":"monday","description":"Required for weekly (lowercase day name) and monthly (integer 1-28).\nOmit for daily.","json_path":"/frequency_details/day"}],"intent":"Create a new scheduled report for a project","guidance":["Create a report configuration under the given project"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"]},{"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","description":"Name of the root folder"},{"name":"notes","type":"string","example":"This folder contains all regression test cases","description":"Optional notes or description for the folder"}],"intent":"Create a root-level folder for test cases in a project","returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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},{"name":"folder_id","type":"integer","description":"ID of the shared-field folder to place the shared step in. Null or omitted means the root (Unassigned)."},{"name":"shared_step_details","type":"array","required":true,"fields":[{"name":"order","type":"integer","required":true},{"name":"step","type":"string"},{"name":"result","type":"string"},{"name":"test_data","type":"string"}]}],"intent":"Create a new shared step in a project","guidance":["read the shared step first and send back the full array with your edit applied, or you will drop steps","Include each detail's id to update it in place rather than recreating it","A shared step is embedded by MANY test cases","so editing or deleting it changes every one of them at once","say how it is used before changing it"],"returns":["id","title","step_count","test_case_count"]},{"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},{"name":"test_run_id","type":"string","required":true},{"name":"test_case_id","type":"integer","required":true},{"name":"step_id","type":"integer","required":true}],"body":[{"name":"status_id","type":"integer","json_path":"/test_run_step_result/status_id"},{"name":"description","type":"string","json_path":"/test_run_step_result/description"}],"intent":"Record a per-step result for a case in a run (v1).","guidance":["record a per-step outcome for a step-templated case ({ status_id, description })"]},{"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,"description":"Name of the new folder.","json_path":"/folder/name"},{"name":"notes","type":"string","description":"Description of the new folder.","json_path":"/folder/notes"}],"intent":"create subfolder inside a specific folder","guidance":["Create a new subfolder within a specific folder in a project"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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 the FIRST test case in a project that has no folders (needs create_at_root).","guidance":["Narrow-purpose route: the first case in a project that has zero folders","The route and the flag always travel together","The product's UI derives both from one boolean","no folders exist AND the user picked no folder","A correct refusal, not something to retry: list the folders and pick one","The text sits under errors[], not message"],"returns":["result","step"]},{"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,"fields":[{"name":"name","type":"string","required":true},{"name":"test_case_folder_id","type":"string","required":true},{"name":"attachments","type":"array"},{"name":"automation_state","type":"integer"},{"name":"automation_status","type":"string"},{"name":"case_type","type":"integer"},{"name":"custom_fields","type":"object"},{"name":"description","type":"string"},{"name":"estimate","type":"string"},{"name":"estimated_duration_seconds","type":"integer"},{"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"},{"name":"reviewers","type":"array"},{"name":"send_for_review","type":"boolean"},{"name":"shared_precondition_id","type":"integer"},{"name":"status","type":"integer"},{"name":"tags","type":"array"},{"name":"template","type":"string"},{"name":"template_id","type":"integer"},{"name":"template_step_type","type":"string"},{"name":"test_case_dataset","type":"string"},{"name":"test_case_steps","type":"array"}]},{"name":"create_at_root","type":"boolean"}],"intent":"Create test cases for a folder in a project.","guidance":["body wrapped in test_case, name required","Create one or more test cases within a specific folder in a project","If create_at_root is true, the test case will be created at the root of the project"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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","json_path":"/test_plan/name"},{"name":"description","type":"string","json_path":"/test_plan/description"},{"name":"start_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/start_date"},{"name":"end_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/end_date"},{"name":"plan_status","type":"string","description":"Plan status. The list filter accepts new / started / completed.","json_path":"/test_plan/plan_status"},{"name":"owner","type":"integer","description":"Owner user id \u2014 resolve via the project users read first.","json_path":"/test_plan/owner"},{"name":"parent_plan_id","type":"integer","description":"Parent plan's INTEGER id. Set it to create (or re-parent into) a SUB-plan \u2014 the\nresult carries an STP-NNN identifier. Null/omitted means a top-level plan.","json_path":"/test_plan/parent_plan_id"},{"name":"tags","type":"array","json_path":"/test_plan/tags"},{"name":"reviewers","type":"array","description":"Reviewer user ids.","json_path":"/test_plan/reviewers"},{"name":"attachments","type":"array","description":"Attachment ids already uploaded via the attachments surface.","json_path":"/test_plan/attachments"},{"name":"custom_fields","type":"object","json_path":"/test_plan/custom_fields"},{"name":"issues","type":"array","description":"Linked tracker issues. Structured \u2014 NOT a flat array of keys.","fields":[{"name":"issue_id","type":"string"},{"name":"issue_type","type":"string"},{"name":"metadata","type":"object"}],"json_path":"/test_plan/issues"},{"name":"test_runs","type":"string","description":"The plan's linked test runs. Accepts EITHER an array of test-run integer ids, OR a\nbulk-selection object for \"every run matching this filter\". On update this REPLACES\nthe existing link set rather than appending.","json_path":"/test_plan/test_runs"}],"intent":"Create a test plan \u2014 or a SUB-plan, by setting parent_plan_id","guidance":["body wrapped in test_plan","Create a test plan in the project","Everything goes under the test_plan key","test_runs accepts two shapes","Both are honoured server-side","an unknown option is rejected"]},{"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"},{"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":["The case is in the PATH here, not the body","Bodies are wrapped in test_result","set status by NAME (status) or id (status_id)","use a real configured status (see statuses-and-states)","so a stray string silently CREATES a link","Deleting removes an execution record and the case's latest status is recomputed from what remains, which can silently change the run's reported state"],"returns":["id","status","backtrace","configuration_id","created_at","created_by_imported","custom_fields","description","error_description","expanded","failure_type","finished_at"]},{"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],"description":"Explicit case ids to pin into the run. Top level, NOT inside `test_run`. Use this or `selection`."},{"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":["Create a new test run for a specific project, which can be used to execute test cases"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"dataset_id","type":"integer","required":true},{"name":"option_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"Delete one option (POST to /delete) \u2014 DESTRUCTIVE, drops values using it.","guidance":["destroys the values that used it","Removing an option deletes the stored values that used it, across every project linked to the dataset","that preserves the values"]},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"Delete a dataset (POST to /delete) \u2014 DESTRUCTIVE, drops its options and values.","guidance":["drops its options and the values the linked projects stored","Removes the option set and unlinks its projects, destroying the values those projects had stored for this field","Name the affected projects before calling"]},{"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}],"intent":"Delete a field definition (POST to /delete) \u2014 DESTRUCTIVE, cascades to stored values.","guidance":["destroys every stored value in every linked project","Name the projects first","Removes the definition and every value stored for it, in every project it is linked to","There is no bin and no undo","Before calling: read the field, say how many projects it is linked to, and name them","If the user only wants it hidden rather than destroyed, that is a different ask"]},{"method":"DELETE","path":"/api/v1/projects/{project_id}/datasets/{uuid}","mode":"destructive","entity":"dataset","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"uuid","type":"string","required":true}],"intent":"Delete a dataset.","guidance":["say it isn't available rather than calling it, and don't substitute by parsing the file yourself","A 422 here means NOT-ENTITLED, not a bad payload","say so and stop, don't retry with a different body","BINDING shape is DIFFERENT from the dataset shape, and this is the usual failure","the dataset's uuid repeated on every column it owns","The binding object has NO top-level uuid or name (both stripped by strong params)"]},{"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":"Delete an exploratory session","guidance":["defects are the issues linked to the session","the parent project takes the INTEGER project id (v1)","List defaults to active sessions"]},{"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":"Delete a session log entry","guidance":["Permanently deletes a log entry from the specified exploratory session"]},{"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 filter","guidance":["entity = testcase | testplan | testrun | exploratory_session","filter_id is an integer","reuse a saved view's filters as the q for a later search or bulk action"]},{"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":"Delete a folder and its contents","guidance":["Deletes a folder by its ID and optionally returns the parent folder's path hierarchy"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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"}],"intent":"Delete a report (this removes the report itself, not just its schedule)","guidance":["returns the remaining reports","merely stopping a report from recurring","The response is the list of remaining reports","There is no bin and no undo","so confirm the report's name with the user first"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"]},{"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","guidance":["affects every test case embedding it","Deletes a shared step from a project"]},{"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":"Delete an attachment from a test case in a folder in a project","guidance":["read the file from that url"],"returns":["id","byte_size","content_type","filename"]},{"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":"Delete a test plan (or sub-plan) \u2014 no bin, no undo","guidance":["The server performs no plan-type check","so this deletes a sub-plan by its own integer id exactly the same way","Deleting a parent plan is destructive to its children"]},{"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 test result","guidance":["Prefer PATCHing the latest result to correct a mistake","Deleting a result removes an execution record","the case's latest status is recomputed from what remains"]},{"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"}],"intent":"delete test run","guidance":["The discovered ids must be CARRIED INTO the create body","discovery alone puts nothing in the run","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","It is validated before the selection","so a bare 400 'invalid data' means a bad test_run field"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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},{"name":"duplicate_id","type":"integer","required":true}],"body":[{"name":"discard_reason","type":"string","description":"Short reason code / label for why this isn't a duplicate."},{"name":"user_feedback","type":"string","description":"Free-text feedback from the user."}],"intent":"Discard a duplicate suggestion \u2014 dismisses it WITHOUT touching test cases","guidance":["The safe default when the user says it's not a duplicate or is unsure","Mark a recommendation as discarded","This is the safe way to dismiss a suggestion: it changes only the recommendation's status to discarded and leaves both test cases exactly as they are","discard_reason and user_feedback are optional but feed the dedupe model","pass along whatever reason the user gave"]},{"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":"Desired export format. Note this is a STRING here, while the same-named field\non `send_email_now` is an ARRAY."},{"name":"required","type":"object","description":"Optional column selection, honoured only for `Test Run Detailed Report`\n(ignored for every other type). Shape:\n`{ \"\": { \"fields\": [\"id\",\"title\",...] } }`. Omit to get the report's\ndefault columns."}],"intent":"Initiate a download of a report in a specified file format","guidance":["Request generation and download channel for the specified report"],"returns":["channel"]},{"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"},{"name":"custom_fields","type":"object","example":{"123":"option_value","456":["multi","select"]},"json_path":"/test_case/custom_fields"},{"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"},{"name":"preconditions","type":"string","json_path":"/test_case/preconditions"},{"name":"priority","type":"integer","example":483,"json_path":"/test_case/priority"},{"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\".","json_path":"/test_case/reviewers"},{"name":"status","type":"integer","example":496,"json_path":"/test_case/status"},{"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":"string","description":"Dataset binding for a data-driven case. On this path it is applied only when `is_dataset_modified` is true.","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"}],"intent":"Partially edit a test case (v2)","guidance":["PREFERRED partial update","send ONLY changed fields","Partially update the details of a test case within a specific folder in a project","it is the authoritative list","send test_case_steps instead - estimate is accepted and then dropped","estimated_duration_seconds is the one that persists"],"returns":["result","step"]},{"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"},{"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"},{"name":"custom_fields","type":"object","json_path":"/test_case/custom_fields"},{"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"},{"name":"name","type":"string","json_path":"/test_case/name"},{"name":"owner","type":"integer","json_path":"/test_case/owner"},{"name":"preconditions","type":"string","json_path":"/test_case/preconditions"},{"name":"priority","type":"integer","json_path":"/test_case/priority"},{"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"},{"name":"tags","type":"array","json_path":"/test_case/tags"},{"name":"template","type":"string","json_path":"/test_case/template"},{"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":"string","description":"Dataset binding for a data-driven case. On create the mappings are always regenerated from this, so `is_dataset_modified` is ignored here.","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","json_path":"/test_case/test_case_steps"}],"intent":"Edit a test case","guidance":["Update the details of a test case within a specific folder in a project","Omitted fields are left untouched EXCEPT template, template_id and review_status, which are reset to defaults"],"returns":["result","step"]},{"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"}],"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],"description":"Explicit case ids to pin into the run. Top level, NOT inside `test_run`. Use this or `selection`."},{"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":"Edit Test Run","guidance":["Update the details of a specific test run within a project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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","pdf","excel"],"example":"csv","description":"Export file format"},{"name":"start_date","type":"string","example":"2025-01-01","json_path":"/filters/start_date"},{"name":"end_date","type":"string","example":"2025-12-31","json_path":"/filters/end_date"},{"name":"status","type":"array","example":["passed","failed"],"json_path":"/filters/status"}],"intent":"Export dashboard analytics data","guidance":["Initiates an asynchronous export of dashboard analytics data in the specified format","The export is processed via background job and returns an export ID for tracking","Async Processing: Data export runs via Sidekiq ExportDashboardWorker::FileExportWorker","Supported Formats: CSV, PDF, Excel (based on type parameter)"],"returns":["export_id"]},{"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":"Get active test run result statistics","guidance":["Retrieve statistics of active test runs for a specific project"],"shape":"discovered"},{"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":"Get archived test cases.","guidance":["Retrieve a list of archived test cases in a specific project"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"Count archived test plans","guidance":["Returns {success, count}","the number of archived plans in the project"],"shape":"discovered"},{"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 automation stats analytics","guidance":["Retrieve automation statistics for a specific project"],"returns":["automated_coverage","automated_test_cases","empty_data","manual_test_cases","total_test_cases"]},{"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":"Get all the closed test runs for a project","guidance":["Retrieve a list of all closed test runs for a specific project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"Get closed test run analytics","guidance":["Retrieve statistics of closed test runs for a specific project"],"returns":["month"]},{"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":"Get closed test runs split analytics","guidance":["Retrieve split statistics of closed test runs for a specific project"],"returns":["name"]},{"method":"GET","path":"/api/v1/projects/{project_id}/configurations","mode":"read","entity":"configuration","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Get configurations for a project","guidance":["Retrieve a list of configurations for a specific project"],"returns":["id","name","created_at","group_id","is_suggested","record_status"]},{"method":"GET","path":"/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets/{dataset_id}","mode":"read","entity":"custom_field","path_params":[{"name":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"Read one dataset \u2014 its project links and option set.","guidance":["If nothing matches, the field doesn't exist in that workspace","say so rather than guessing a field id","Multi-dropdowns need OPTION IDS, not labels","That legacy family writes a table local to the Rails app that is DEPRECATED and that nothing reads","It is blocked at the gate","testhub owns definitions"],"shape":"discovered"},{"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}],"intent":"Read one custom field definition, with its datasets and project links.","guidance":["do this BEFORE any update, which is a full replace","Options are NOT inline","Datasets come back WITHOUT their options inline","Read this before any update","so you need the current values to avoid clearing them"],"shape":"discovered"},{"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},{"name":"entity_type","type":"string","required":true,"values":["test-case","test-result","test-plan"]},{"name":"field_id","type":"string","required":true}],"query":[{"name":"q","type":"string"},{"name":"p","type":"integer"}],"intent":"Get the allowed values/options of one custom field.","guidance":["If nothing matches, the field doesn't exist in that workspace","say so rather than guessing a field id","Multi-dropdowns need OPTION IDS, not labels","That legacy family writes a table local to the Rails app that is DEPRECATED and that nothing reads","It is blocked at the gate","testhub owns definitions"],"shape":"discovered","paginated":true},{"method":"GET","path":"/api/v1/projects/{project_id}/datasets/{uuid}","mode":"read","entity":"dataset","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"uuid","type":"string","required":true}],"intent":"Get a specific dataset.","guidance":["read one dataset by UUID","Retrieve a specific dataset by its UUID including all variables and rows"],"returns":["name","created_at","rows_count","uuid"]},{"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},{"name":"uuid","type":"string","required":true}],"query":[{"name":"p","type":"integer"}],"intent":"Get test cases linked to a dataset","guidance":["list the test cases bound to this dataset (paged p)","Returns the test cases linked to a dataset, identified by its UUID"],"shape":"discovered","paginated":true},{"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"},{"name":"p","type":"integer"}],"intent":"Get dataset variables/columns.","guidance":["BINDING shape is DIFFERENT from the dataset shape, and this is the usual failure","the dataset's uuid repeated on every column it owns","The binding object has NO top-level uuid or name (both stripped by strong params)","so do NOT copy the dataset's read shape into it","Never report a dataset as linked from the 200 alone","A dataset is addressed by a UUID (the dataset path param), NOT an integer"],"returns":["name","uuid"],"paginated":true},{"method":"GET","path":"/api/v1/projects/{project_id}/ai-duplicates/status","mode":"read","entity":"duplicate","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Dedupe job status \u2014 use this to tell \"feature off\" from \"no duplicates\"","guidance":["ALWAYS call this when the list is empty","Status of the project's most recent dedupe run"],"returns":["status"]},{"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},{"name":"duplicate_id","type":"integer","required":true}],"intent":"Read the pre-merge snapshot of a MERGED duplicate's source test case","guidance":["after a merge, read the pre-merge snapshot of the case that was folded away","After a merge, the source case no longer exists independently","this returns the snapshot captured at merge time","the only way to show the user what was folded away","Only works for status merged (404 otherwise), and the snapshot may legitimately be absent, which also surfaces as a 404 with error: \"Source test case snapshot not available\"","Treat that as \"not retained\", not as an error to retry"],"shape":"discovered"},{"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},{"name":"duplicate_id","type":"integer","required":true}],"intent":"Read one suggested duplicate, with its test cases resolved","guidance":["do this BEFORE merging so the user can see what would be destroyed","Only status=suggested is readable","Full detail for one recommendation, including the actual test cases it links","so you can show the user what would be merged","Only status suggested is readable here","already-resolved duplicates return 404"],"shape":"discovered"},{"method":"GET","path":"/api/v1/projects/{project_id}/{entity}/filter-details","mode":"read","entity":"project","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"entity","type":"string","required":true,"values":["test-cases","trtc","test-runs"],"example":"test-cases"}],"query":[{"name":"q[query]","type":"string","example":"login functionality"},{"name":"q[folder_ids]","type":"string","example":"3306878,3669741,5422801,5422802"},{"name":"q[status]","type":"string","example":"9319,9321"},{"name":"q[priority]","type":"string","example":"550376,9261"},{"name":"q[automation_state]","type":"string","example":"18172471,18172473"},{"name":"q[case_type]","type":"string","example":"9379,9375"},{"name":"q[owner]","type":"string","example":"user123,user456"},{"name":"q[assignee]","type":"string","example":"user789,user101"},{"name":"q[tags]","type":"string","example":"regression,smoke"},{"name":"q[created_at]","type":"string","example":"2024-01-01..2024-12-31"},{"name":"q[updated_at]","type":"string","example":"2024-01-01..2024-12-31"}],"intent":"Get filter details for entity","guidance":["Retrieve filter details for a specific entity type (test-cases, trtc, or test-runs) within a project"],"returns":["id","colour","entity_type","field_name","field_value","internal_name"]},{"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":"Get an exploratory session","guidance":["read one session by INTEGER id","Returns a single exploratory session by its ID"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"]},{"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"]},{"name":"start_date","type":"string"},{"name":"end_date","type":"string"},{"name":"session_ids","type":"string"}],"intent":"Exploratory Session Summary \u2014 live view, no saved report needed","guidance":["exploratory-session summary WITHOUT creating a report","Computes an exploratory-session summary on demand","there is no Schedule row behind it","Use it to answer \"summarise our exploratory sessions\" without creating a report first","Returns session counts, bugs found, top testers (resolved to user objects) and the session list"],"shape":"discovered"},{"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":"Get filter details","guidance":["Retrieve details of a specific saved filter by its ID","It does NOT validate or strip invalid custom-field references","a filter saved with a non-existent custom-field id is returned unchanged","A missing or deleted filter comes back as 400 {\"success\": false, \"message\": \"Filter not found\"}, not 404"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"]},{"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":"Get summary of entities to be deleted when removing a folder","guidance":["PREVIEW what deleting a folder will remove before calling rm","Provides a summary of all entities (test cases, recordings, sub-folders) that would be deleted if the folder is removed"],"returns":["active_test_case_count","archived_test_case_count","sub_folders","test_recordings_count"]},{"method":"GET","path":"/api/v1/projects/{project_id}/repository/mapping","mode":"read","entity":"folder","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Get the project's folder tree (v1).","guidance":["the whole project folder TREE in one call (structure array)"],"shape":"discovered"},{"method":"GET","path":"/api/v1/group/users-v2","mode":"read","entity":"user","query":[{"name":"q","type":"string"}],"intent":"Get group users V2 with pagination","guidance":["Retrieve a paginated list of users in the group with search and filtering capabilities, WITHOUT project scoping","used by the Global Search filter dropdowns","Search by user IDs (comma-separated) or by a name string"],"returns":["id","browserstack_user_id","email","full_name","group_id","onboarded","reports_and_notification_enabled"]},{"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":"Get issues count analytics","guidance":["Retrieve the count of issues for a specific project"],"returns":["label"]},{"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","guidance":["so for an integer id this is the right read","Do NOT translate first just to fetch the project","It serves two jobs at once: (1) READ","returns the project's full detail (name, description, permissions, \u2026)","In other words it is NOT translation-only"],"returns":["id","identifier","name","created_at","description","jira_mapped","starred","test_cases_count","test_plans_count","test_runs_count","thProjectId","user_role"]},{"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}],"intent":"Get project-specific custom fields V2 for test cases","guidance":["START HERE for CUSTOM fields","Does NOT expose system-field option ids","Retrieve custom fields, default fields, and system fields for test cases in a specific project (V2 format)"],"returns":["id","default_value","entity_type","field_name","field_type","group_id","is_bulk_editable","is_filterable","is_required","linked_projects_count","place_holder_text","system_name"]},{"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"]}],"intent":"Get project settings","guidance":["Returns an array of enabled setting keys"],"returns":["in_review_count","test_plan_in_review_count"]},{"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"}],"intent":"Get project users V2 with pagination","guidance":["Retrieve paginated list of users in the group with search and filtering capabilities","Search by user IDs (comma-separated) or by name string"],"returns":["id","browserstack_user_id","email","full_name","group_id","onboarded","reports_and_notification_enabled"]},{"method":"GET","path":"/api/v1/projects/basic","mode":"read","entity":"project","query":[{"name":"p","type":"integer"},{"name":"count","type":"integer"},{"name":"q[query]","type":"string","example":"nishchay3"},{"name":"q[identifier]","type":"string","example":"PR-60863"}],"intent":"Get paginated projects (lean payload) \u2014 use this to list or count projects","guidance":["so you can reach a true total without truncating","Those two are the only recognised keys"],"returns":["id","name","description","import_id","normalisedName","starred","thProjectId"],"max_page_size":300,"paginated":true},{"method":"GET","path":"/api/v1/projects/minify","mode":"read","entity":"project","query":[{"name":"p","type":"integer"},{"name":"count","type":"integer"}],"intent":"Get all projects (minified dropdown) \u2014 has NO name search","guidance":["never use it to resolve a named project or to count them","Minified list of active projects, for dropdown-style selection","It accepts no q in any form","the backend call has no query option","so it can only page through everything","Do NOT use it to resolve a project the user named"],"returns":["id","name","description","import_id","normalisedName","starred","thProjectId"],"max_page_size":300,"paginated":true},{"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},{"name":"attachment_id","type":"integer","required":true}],"intent":"Get download URL for a report attachment","guidance":["Retrieve a secure URL to download a specific report attachment"],"returns":["url"]},{"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"},{"name":"report_data_only","type":"boolean"},{"name":"p","type":"integer"},{"name":"today","type":"string","example":"2025-05-13"}],"intent":"Retrieve a scheduled report's config and data \u2014 the general report read","guidance":["works for every report type","Full configuration plus computed data for a report of ANY type","request the section and look at the result","which is a real finding","Pass sections to compute the sections you need","several in ONE call, cheaper than one request per section"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"],"paginated":true},{"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"}],"query":[{"name":"widget_type","type":"string"},{"name":"segment","type":"string"},{"name":"p","type":"integer"}],"intent":"Fetch one report widget/section \u2014 works for EVERY report type.","guidance":["Section names are validated per type","*_drilldown sections return paginated ROWS, not aggregates","Returns a single section's data for any report type","This is the general section read","so when you need SEVERAL sections, prefer that form with a comma-separated sections list and take them in one call rather than one request per section","Most sections return the computed aggregate for a widget"],"shape":"discovered","paginated":true},{"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","requirement_traceability_report"]}],"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":"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`"},{"name":"sections","type":"string","example":"test_run_data,test_run_status"},{"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"]},{"name":"segment","type":"string"},{"name":"p","type":"integer"}],"intent":"Get summary statistics for a scheduled report \u2014 run and traceability types ONLY","guidance":["400s on every other type","so not for Test Case Activity or Test Plan Summary","including every other type in ReportType","This is not the general report-read","optionally with sections","reportTimeRange is required"],"shape":"discovered","paginated":true},{"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 of the folder to search for"},{"name":"include_folder_hierarchy","type":"boolean","description":"Whether to include folder hierarchy in the response"},{"name":"p","type":"integer"},{"name":"per_page","type":"integer"}],"intent":"Get all folders and subfolders present in the projects.","guidance":["list root folders (paged with p","Returns all folders and subfolders in a project if it exists in TestHub"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"],"max_page_size":100,"paginated":true},{"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":"Get already selected testcases for a tracebility report","guidance":["Defects Summary and Defects Detailed Report are accepted on create but have no data branch","AND it requires reportTimeRange: omitting it is a 500, not a 400","Section names are per-report-type","a name from another type is a 400","so one bad name rejects the whole call without saying which","See the reports concept for the per-type lists"],"returns":["select_all","unique_test_case_count"]},{"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":"paginated","type":"boolean"},{"name":"p","type":"integer"}],"intent":"Get shared steps","guidance":["read one shared step with its ordered step details","Retrieves a list of shared steps for a project"],"returns":["id","title"],"paginated":true},{"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"},{"name":"q[query]","type":"string"},{"name":"pre_fetch","type":"boolean"}],"intent":"Get all shared steps for a project","guidance":["Returns a list of all shared steps within a project"],"returns":["id","title","step_count","test_case_count"],"paginated":true},{"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"},{"name":"p","type":"integer"}],"intent":"THE source for a system field's option ids (priority/status/case_type/automation_state).","guidance":["a single attachment-URL field is enough to push the response past the ~30 KB cap","Supports q to search the option list and p to page it, which matters","a workspace can accumulate dozens of options on a single field"],"shape":"discovered","paginated":true},{"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":"Get all attachments for a test case in a folder in a project","guidance":["list a case's attachments","Retrieve all attachments associated with a specific test case within a folder","CURRENTLY RETURNING 500","a reproducible server error, not a bad request","Do NOT retry it and do not report it to the user as their mistake"],"returns":["id","byte_size","content_type","filename"]},{"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":"Get a test case by INTEGER id (v1, folder-scoped) \u2014 full detail + its TC-NNN identifier","guidance":["so for an integer id this v1 read is the ONLY way to fetch the case","Two jobs at once: (1) READ","NOT translation-only","Don't fall back to listing the folder to find the case: if you have the integer id, read it here directly"],"returns":["id","identifier","title"]},{"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":"Get test case count trend analytics","guidance":["Retrieve the trend of test case counts for a specific project"],"returns":["field"]},{"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","example":"folder_path"}],"intent":"Get Test Case Details","guidance":["full detail (steps, custom fields, issues) before editing","read this, improve, then write back"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"Get test case histories","guidance":["list all version records for a test case","Retrieve all history records for a specific test case"],"returns":["id","comment","created_at","entity_id","entity_type","group_id","project_id","source","user_id","version_id","version_name"]},{"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}],"intent":"Get test case history diff","guidance":["compare two versions","versions[] with exactly two ids (PAID plan)","Retrieve the diff of a specific history record for a test case"],"returns":["id","order","result","shared_step_id","step"]},{"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":"Get a specific test case history","guidance":["Retrieve details of a specific test case history by its ID"],"returns":["id","comment","created_at","entity_id","entity_type","group_id","project_id","source","user_id","version_id","version_name"]},{"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":"Get tags and metadata for a test case","guidance":["read a case's current tags","Retrieve the tags and metadata associated with a specific test case in a project"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"Get test case type split analytics","guidance":["Retrieve the split of test case types for a specific project"],"returns":["name","field","value","y"]},{"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"}],"query":[{"name":"required[fields]","type":"string","values":["count"]}],"intent":"Get paginated test cases for a test run","guidance":["page the cases in a run (each row is the case you log results against)","Retrieve a paginated list of test cases for a specific test run in a project"],"shape":"discovered"},{"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"},{"name":"per_page","type":"integer"},{"name":"required[fields]","type":"string","example":"owner,priority"},{"name":"required[custom_fields]","type":"string","example":"121,119"},{"name":"all_folders","type":"string","values":["true"],"example":"true"}],"intent":"List a project's test cases \u2014 REQUIRES all_folders=true to be project-wide.","guidance":["Without all_folders=true this returns only ONE folder's cases","the project's first root folder","So a project-wide question answered from a bare call silently under-counts by whatever sits in every other folder, and nothing in the response says so","all_folders is compared as the STRING 'true' (all_folders = params['all_folders'] == 'true')","A JSON boolean true, 1, TRUE or any other value all fail that check and silently fall back to the single-folder scope"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"max_page_size":100,"paginated":true},{"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":"Get a test plan by INTEGER id (v1) \u2014 full detail + its TP-NNN identifier","guidance":["READ one plan by INTEGER id","Read a test plan by its INTEGER id (project integer id in the path)","Don't translate first just to read the plan","this returns its full detail directly","Two jobs at once: (1) READ","the plan's full detail (under test_plan"],"returns":["identifier","name"]},{"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"},{"name":"test_run_id","type":"integer"},{"name":"today","type":"string"}],"intent":"Execution-trend chart data for ONE plan or ONE run (XOR)","guidance":["pass test_plan_id XOR test_run_id (both or neither is a 400)","Time-series execution trend backing the Insights tab chart","Scope it with exactly one of test_plan_id or test_run_id","Passing both returns 400 \"Provide either test_plan_id or test_run_id, not both\"","passing neither returns 400 \"test_plan_id or test_run_id is required\"","This returns chart data, not a widget object"],"shape":"discovered"},{"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 a test plan","guidance":["the other half of the Insights tab","Like execution-trend, this is chart data only","insights widgets themselves are not a CRUD resource here"],"shape":"discovered"},{"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"},{"name":"include_sub_test_plan_runs","type":"boolean"},{"name":"compact","type":"boolean"},{"name":"is_archived","type":"boolean"}],"intent":"List the test runs linked to a test plan","guidance":["Paginated list of the runs a plan groups","that field replaces the link set rather than appending to it","include_sub_test_plan_runs=true also returns runs belonging to the plan's sub-plans"],"shape":"discovered","paginated":true},{"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}],"query":[{"name":"p","type":"integer"},{"name":"per_page","type":"integer"}],"intent":"Get paginated custom fields for test results","guidance":["Retrieve paginated list of custom fields configured for test results in a project"],"returns":["id","default_value","entity_type","field_name","field_type","field_user_name","is_bulk_editable","is_filterable","is_required","optional","placeholder"],"max_page_size":100,"paginated":true},{"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"},{"name":"test_case_id","type":"integer","required":true}],"intent":"Get test results for a test case in a test run","guidance":["read a case's results within a run","Retrieve a list of test results for a specific test case within a test run in a project"],"returns":["id","status","backtrace","configuration_id","created_at","created_by_imported","custom_fields","description","error_description","expanded","failure_type","finished_at"]},{"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":["Read a test run by its INTEGER id (with the project's integer id in the path)","Don't translate first just to read the run","this returns its full detail directly","Two jobs at once: (1) READ","NOT translation-only"],"returns":["id","identifier","name","uuid"]},{"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},{"name":"test_run_id","type":"string","required":true}],"intent":"Get a run with its per-case rows (v1).","guidance":["the run with its per-case rows (did it pass?)"],"shape":"discovered"},{"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"}],"intent":"Get custom field values for test results in test runs","guidance":["Retrieve default field values and optionally custom fields for test results (TRTC - Test Run Test Case)"],"returns":["id","applies_to_all_projects","default_value","entity_type","field_name","field_type","is_required","link_to_future_projects","place_holder_text"]},{"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.","json_path":"/selection/select_all"},{"name":"unique_test_case_count","type":"integer","example":2,"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.","json_path":"/selection/folders"},{"name":"shared_selection","type":"object"}],"intent":"get selected test runs of a project","guidance":["Retrieve selected test runs for a specific project based on the provided selection criteria"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"ignore_run_type","type":"boolean"},{"name":"include_test_plan","type":"boolean"}],"intent":"Get all the test runs for a project","guidance":["list the project's (open) runs, paged with p","Retrieve a list of all test runs for a specific project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"Import a CSV dataset \u2014 NOT SUPPORTED in this profile","guidance":["say CSV import isn't available","CSV import is not supported here","If asked to import a dataset from CSV, say it isn't available and point to the Test Management UI"]},{"method":"GET","path":"/api/v1/activities","mode":"read","entity":"activity","query":[{"name":"project_id","type":"integer"},{"name":"cursor","type":"string"},{"name":"limit","type":"integer"},{"name":"actor_id","type":"integer"},{"name":"starred_only","type":"boolean"},{"name":"entity_type","type":"array"}],"intent":"Read the activity feed (audit trail) \u2014 CURSOR paged, 15-day window","guidance":["WHO changed WHAT recently","group-wide audit trail","Group-wide audit trail of who changed what","Read-only: activity events cannot be created, edited, or deleted","Two things differ from every other list in this profile: - Cursor pagination, not page numbers","so you cannot jump to a page or read a total"],"returns":["no_starred_projects"]},{"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"}],"intent":"List archived test plans","guidance":["where a 'missing' plan usually is, and the source of ids for bulk-retrieve","Paginated list of the project's archived plans","so if a plan the user names seems missing, look here before concluding it was deleted"],"shape":"discovered","paginated":true},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"List a dataset's options with their ids.","guidance":["the ids a filter or a test-case write needs"],"shape":"discovered"},{"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"},{"name":"q[uuid]","type":"string"},{"name":"p","type":"integer"}],"intent":"List datasets for a project.","guidance":["list datasets (paged p","Retrieve a paginated list of datasets for a project with optional filtering by name or UUID"],"returns":["name","created_at","rows_count","test_cases_count","updated_at","uuid"],"paginated":true},{"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"},{"name":"entity_type","type":"string"},{"name":"entity_id","type":"integer"},{"name":"duplicates","type":"string"}],"intent":"List AI-suggested duplicate test cases \u2014 an empty list may mean the feature is OFF","guidance":["LIST the duplicate suggestions awaiting review","List the AI dedupe recommendations awaiting review in a project","Only duplicates with status suggested are returned","once merged, archived, or discarded they leave this list","An empty result is ambiguous","identical to \"this project has no duplicates\""],"shape":"discovered","paginated":true},{"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":"page","type":"integer","example":1},{"name":"per_page","type":"integer","example":30}],"intent":"List defects for an exploratory session","guidance":["List defaults to active sessions","the parent project takes the INTEGER project id (v1)","defects are the issues linked to the session"],"returns":["issue_id","issue_type"],"max_page_size":100,"paginated":true},{"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":"page","type":"integer","example":1},{"name":"per_page","type":"integer","example":50}],"intent":"List logs for an exploratory session","guidance":["page the session's log entries","Returns a paginated list of log entries for the specified exploratory session"],"returns":["id","status","content","created_at","elapsed_time","session_id","updated_at"],"max_page_size":100,"paginated":true},{"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":"page","type":"integer","example":1},{"name":"per_page","type":"integer","example":30},{"name":"q[session_state]","type":"string","values":["active","closed"],"example":"active"},{"name":"q[assignee]","type":"integer","example":42},{"name":"q[owner]","type":"integer","example":42},{"name":"q[created_by]","type":"integer","example":10},{"name":"q[created_at_from]","type":"string","example":"2026-01-01"},{"name":"q[created_at_to]","type":"string","example":"2026-01-31"},{"name":"q[tags][]","type":"array","example":["regression","payment"]},{"name":"q[configuration_ids][]","type":"array","example":[1,2]},{"name":"q[search]","type":"string","example":"checkout"},{"name":"q[status]","type":"string","example":"pass"}],"intent":"List exploratory sessions for a project","guidance":["list sessions (defaults to active","Returns a paginated list of exploratory sessions"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"],"max_page_size":100,"paginated":true},{"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","type":"string","required":true,"values":["testcase","testplan","testrun","exploratory_session","test_plan_test_case"]},{"name":"page","type":"integer"}],"intent":"List filters","guidance":["list saved filter views (optional entity = testcase|testplan|testrun|exploratory_session)","Retrieve a paginated list of saved filters for a project, optionally filtered by entity type"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"],"paginated":true},{"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":"List subfolders and folder metadata for a specific folder in a project","guidance":["one folder's detail + its direct subfolders + ancestors"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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"},{"name":"per_page","type":"integer"},{"name":"required[fields]","type":"string","example":"owner,priority"},{"name":"required[custom_fields]","type":"string","example":"121,119"}],"intent":"List the test cases in one folder","guidance":["Page through the cases directly inside a folder, by the folder's INTEGER id","Use this when the user has already named or selected a folder","Rows are verbose and list reads truncate around 30 KB"],"shape":"discovered","max_page_size":100,"paginated":true},{"method":"GET","path":"/api/v1/projects","mode":"read","entity":"project","query":[{"name":"q","type":"string","example":"nishchay3"},{"name":"p","type":"integer"},{"name":"sort_by","type":"string"},{"name":"starred","type":"boolean"},{"name":"shared","type":"boolean"},{"name":"is_hidden_projects","type":"boolean","example":true}],"intent":"List projects for a group.","guidance":["Paginated list of the group's projects","query and page are not read by the server","so use this to FIND a project, not to enumerate them"],"returns":["id","identifier","name","created_at","description","import_id","project_creator","test_cases_count","test_runs_count"],"paginated":true},{"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"},{"name":"q[scheduled]","type":"string","values":["true","false"]},{"name":"p","type":"integer"}],"intent":"List all the scheduled reports","guidance":["list the project's report schedules (paged p","Retrieve a paginated list of schedules"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"],"paginated":true},{"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"},{"name":"q","type":"string"}],"intent":"List test-case tags (paginated).","guidance":["list a project's test-case tags (paged with p, optional q)"],"shape":"discovered","paginated":true},{"method":"GET","path":"/api/v1/admin-v2/settings/templates","mode":"read","entity":"","query":[{"name":"entity_type","type":"string","values":["TestCase","TestResult","TestPlan","ExploratorySession"]},{"name":"project","type":"integer"},{"name":"name","type":"string"},{"name":"p","type":"integer"}],"intent":"List the workspace's test-case templates \u2014 THE source for `template_id`.","guidance":["Ids are per-workspace","so one carried over from another workspace (or from an example) produces the generic 400 \"The API request is invalid or improperly formed\" on a test-case create, with nothing naming the offending field","Call this when the user asked for a named template, or to confirm a template id before reusing one","One call therefore yields both fields","NOTE THE CASING: entity_type here is CamelCase (TestCase), unlike the hyphenated test-case used in path segments elsewhere","Passing test-case returns an empty list with a 200"],"shape":"discovered","paginated":true},{"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"},{"name":"include_sub_plans","type":"boolean"},{"name":"status","type":"string","values":["new","started","completed"]},{"name":"sort_by","type":"string"},{"name":"minify","type":"boolean"}],"intent":"List test plans in a project (optionally including sub-plans)","guidance":["include_sub_plans=true to see sub-plans too","Paginated list of the project's test plans","This is how you find a plan's integer id before reading, updating, or deleting it","so never try to find a plan through search","Without it you see only top-level plans and a plan's children are invisible"],"shape":"discovered","paginated":true},{"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"]},{"name":"field_source","type":"string","values":["system","custom","all"]},{"name":"field_type","type":"string"},{"name":"q","type":"string"},{"name":"p","type":"integer"}],"intent":"THE canonical workspace field list (system + custom) \u2014 start here for definitions.","guidance":["workspace-wide list of DEFINITIONS across all projects","'does a field called X exist anywhere?'","The authoritative list (testhub-backed)","This is the authoritative definition list: it is backed by testhub, which owns custom-field definitions","That legacy collection reads a DEPRECATED table local to the Rails app that nothing else consults","its contents are unrelated to the real fields"],"shape":"discovered","paginated":true},{"method":"POST","path":"/api/v1/projects/{project_id}/tags/merge","mode":"write","entity":"tag","path_params":[{"name":"project_id","type":"integer","required":true}],"body":[{"name":"source_id","type":"integer","required":true},{"name":"target_id","type":"integer","required":true},{"name":"entity_type","type":"string","required":true,"values":["test_case","test_run","test_plan","shared_step"]}],"intent":"Merge one tag into another (v1). Admin-gated (tags_management).","guidance":["merge one tag into another ({ source_id, target_id, entity_type })"]},{"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","required":true,"example":123,"description":"ID of the new parent folder (internal APIs)"},{"name":"new_base_project_id","type":"integer","example":456,"description":"Optional destination project ID for cross-project moves"},{"name":"user_action","type":"string","example":"move_folder"}],"intent":"Move a test case folder to a different parent folder","guidance":["move a folder ({ new_base_folder_id, new_base_project_id })","cross-project moves run async","Move a test case folder to a new parent folder within the same project","This operation updates the folder's hierarchy without changing its contents"],"returns":["async","unique_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","required":true,"description":"Name of the new folder.","json_path":"/folder/name"},{"name":"notes","type":"string","description":"Description of the new folder.","json_path":"/folder/notes"}],"intent":"rename a folder in a project","guidance":["Rename an existing folder within a specific project"],"returns":["request_trace_id"]},{"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],"description":"List of folder IDs to reorder"},{"name":"prev","type":"integer","example":101,"description":"ID of the folder that will precede the moved folder"},{"name":"next","type":"integer","example":103,"description":"ID of the folder that will follow the moved folder"}],"intent":"Reorder folders in a project","guidance":["Reorder folders within a project by specifying the current order and optionally the previous and next folder IDs"],"returns":["request_trace_id"]},{"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":"ID of the test case that will be above the moved ones.","json_path":"/re_order/top_test_case"},{"name":"bottom_test_case","type":"string","description":"ID of the test case that will be below the moved ones.","json_path":"/re_order/bottom_test_case"},{"name":"page","type":"integer","description":"Page number for paginated reordering.","json_path":"/re_order/page"},{"name":"test_cases","type":"array","required":true,"description":"Array of test case IDs to be moved.","json_path":"/re_order/test_cases"}],"intent":"Reorder test cases within a folder of a project","guidance":["Reorders test cases in a folder by placing them between top_test_case and bottom_test_case"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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. Passed in the BODY, not the path."},{"name":"merge_action","type":"string","required":true,"description":"`merge` folds source into target; any other value archives."},{"name":"source_testcase_id","type":"integer","description":"Required for merge \u2014 the case that will cease to exist independently."},{"name":"target_testcase_id","type":"integer","description":"Required for merge \u2014 the case that survives."}],"intent":"Resolve a duplicate by MERGING or ARCHIVING \u2014 destroys or hides a test case","guidance":["RESOLVE a suggestion","merge_action=merge folds source_testcase_id into target_testcase_id (DESTRUCTIVE to a test case), anything else archives","duplicate_id goes in the BODY","Act on one dedupe recommendation","merge_action selects what happens: - \"merge\"","folds source_testcase_id into target_testcase_id"]},{"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":"Restore a specific history entry","guidance":["roll the case back to this version (PAID plan","Restore a specific history entry by its ID"]},{"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":"page","type":"integer"},{"name":"duplicates","type":"string","description":"Duplicate-type filter."},{"name":"owner","type":"string","description":"Owner tab \u2014 scopes to the caller's own duplicates vs all."}],"intent":"Filtered search over suggested duplicates","guidance":["filtered search over suggestions (owner tab, duplicate type, test-case attributes)","Subject to the identical flag caveat: an empty list may mean the feature is off rather than that there are no duplicates"],"shape":"discovered","paginated":true},{"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"}],"query":[{"name":"q","type":"string"},{"name":"p","type":"integer"}],"intent":"Search group tags for an entity type","guidance":["search tags across the whole group (entity_type = test-case|test-run|test-plan)","Retrieve a paginated list of tags for the given entity type across the group (no project scoping)","used by the Global Search filter dropdowns"],"returns":["name","created_at","usage_count"],"paginated":true},{"method":"GET","path":"/api/v1/projects/{project_id}/{entity}/search","mode":"read","entity":"project","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"entity","type":"string","required":true,"values":["test-cases","test-runs","test-plans","reports"]}],"query":[{"name":"q[query]","type":"string","example":"tc"},{"name":"q[folder_id]","type":"integer","example":29529465},{"name":"use_bstack_id","type":"string","values":["0","1"]},{"name":"p","type":"integer"},{"name":"page_size","type":"integer"},{"name":"include_test_plan","type":"boolean"}],"intent":"Search for entities within a project","guidance":["Returns paginated results matching the search criteria"],"shape":"discovered","max_page_size":100,"paginated":true},{"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"},{"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 "},{"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":["Note: Array values in the q parameter are automatically converted to comma-separated strings for compatibility with existing search logic"],"shape":"discovered","max_page_size":100,"paginated":true},{"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"},{"name":"p","type":"integer"},{"name":"page_size","type":"integer"}],"intent":"Search users with access to a project","guidance":["Retrieves a paginated list of users who have access to the specified project","Returns user details including permissions, feature flags, and product roles"],"returns":["id","browserstack_user_id","customisable_dashboard_accessible","full_name","group_id","has_access","is_build_listing_enabled","is_delighted_visible","is_jira_app_project_panel_visible","is_lcnc_explore_dismissed","is_new_project_settings_visible","is_onboarding_project_header_visible"],"max_page_size":100,"paginated":true},{"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"},{"name":"p","type":"integer"},{"name":"page_size","type":"integer"}],"intent":"Search test case tags","guidance":["Retrieves a paginated list of tags associated with test cases in the project","Returns both simple tag strings and detailed tag objects with metadata"],"returns":["name","created_at","usage_count"],"max_page_size":100,"paginated":true},{"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":"query","type":"string"},{"name":"use_bstack_id","type":"string","values":["0","1"]}],"intent":"Search test cases in a project","guidance":["and see pagination-and-filtering for the key table and each value's source","there is NO top-level values array, and looking for one finds nothing","so it is routinely cut off and appears absent","NEVER probe candidate integers for an id","The four system option fields","a name silently returns 0 because the server matches an id column"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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","example":["qa-leads@company.com"],"description":"Recipient addresses. MUST be an array, even for one address."},{"name":"file_type","type":"array","example":["pdf"],"description":"Formats to attach. MUST be an array. Defaults to [\"pdf\"]."}],"intent":"Email a report immediately (async job).","guidance":["Kicks off a background send","a 200 means the job was queued, NOT that mail was delivered","don't report it as sent","Both body fields are ARRAYS and are rejected with 400 \" should be an array\" if sent as scalars","Omitting emails sends to the report's configured recipients","omitting file_type defaults to [\"pdf\"]"]},{"method":"POST","path":"/api/v1/projects/{project_id}/folder/{folder_id}/undo-rm","mode":"write","entity":"folder","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"folder_id","type":"integer","required":true}],"intent":"Undo folder deletion","guidance":["Restores a previously deleted folder and returns its metadata along with path hierarchy"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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":"dataset_id","type":"integer","required":true},{"name":"option_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"body":[{"name":"option_value","type":"string","required":true,"description":"The option label."},{"name":"is_default","type":"boolean","required":true,"description":"REQUIRED \u2014 a missing value is a 400. Must be 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 parent option this hangs under."}],"intent":"Rename an option or change its default (POST to /update).","guidance":["NON-destructive, stored values follow the option id","Both option_value and is_default are required","a missing is_default is a 400","Renaming an option keeps the stored values pointing at it (the option id is unchanged)","so this is the NON-destructive way to fix a label"]},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"body":[{"name":"link_projects","type":"array","description":"Project INTEGER ids to ADD. Note the spelling \u2014 not `linked_projects`."},{"name":"unlink_projects","type":"array","description":"Project INTEGER ids to REMOVE. Destroys their stored values."},{"name":"link_to_future_projects","type":"boolean"},{"name":"select_all","type":"boolean","description":"Apply to every project; may switch the call to async."}],"intent":"Link or unlink projects for a dataset \u2014 how you change which projects see a field.","guidance":["Unlinking destroys that project's values","The keys here are link_projects and unlink_projects","Send the wrong spelling and it is silently dropped: 200, nothing changed","This is the single easiest mistake on this surface","These are DELTAS, not the complete new list: send only the projects to add and the projects to remove","Unlinking is destructive"]},{"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,"description":"Display label. Editable. REQUIRED even when unchanged."},{"name":"field_type","type":"string","required":true,"description":"REQUIRED for validation, but any change is silently ignored."},{"name":"is_required","type":"boolean","required":true,"description":"REQUIRED \u2014 omitting it is a 400."},{"name":"entity_type","type":"string"},{"name":"place_holder_text","type":"string"},{"name":"default_value","type":"string","description":"Only applied when `field_type` is `boolean`."}],"intent":"Update a field definition's label, requiredness or placeholder (POST to /update).","guidance":["Full replace: field_name + field_type + is_required all required","field_type changes are silently ignored","field_name, field_type and is_required must all be present","Omitting is_required is 400 \"Invalid Params\"","omitting field_name is a 500","field_name IS editable here"]},{"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","description":"Updated name of the dataset.","json_path":"/dataset/name"},{"name":"variables","type":"array","description":"Updated list of dataset variables/columns (max 40).","fields":[{"name":"name","type":"string","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/variables"},{"name":"rows","type":"array","description":"Updated list of dataset rows (max 100).","fields":[{"name":"row_number","type":"integer","required":true},{"name":"row_data","type":"array","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/rows"}],"intent":"Update a dataset.","guidance":["Update an existing dataset by its UUID with new variables and rows"],"returns":["name","created_at","rows_count","uuid"]},{"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 for the session.","json_path":"/exploratory_session/title"},{"name":"description","type":"string","description":"Updated description.","json_path":"/exploratory_session/description"},{"name":"charter","type":"string","description":"Updated testing charter.","json_path":"/exploratory_session/charter"},{"name":"timebox_duration","type":"integer","example":90,"description":"Updated planned duration in minutes.","json_path":"/exploratory_session/timebox_duration"},{"name":"duration","type":"integer","example":45,"description":"Tracked duration in minutes.","json_path":"/exploratory_session/duration"},{"name":"actual_duration","type":"integer","example":50,"description":"Final actual duration in minutes.","json_path":"/exploratory_session/actual_duration"},{"name":"folder_id","type":"integer","example":789,"description":"Move the session to a different folder.","json_path":"/exploratory_session/folder_id"},{"name":"owner","type":"integer","example":55,"description":"TCM user ID of the new owner / assignee.","json_path":"/exploratory_session/owner"},{"name":"assignee","type":"integer","example":55,"description":"Alias for `owner`.","json_path":"/exploratory_session/assignee"},{"name":"tags","type":"array","example":["smoke","payment"],"description":"Replace the session's tags with this list.","json_path":"/exploratory_session/tags"},{"name":"configurations","type":"array","example":[2,4],"description":"Replace the session's configuration IDs.","json_path":"/exploratory_session/configurations"},{"name":"attachments","type":"array","description":"Updated set of blob / media attachment IDs.","json_path":"/exploratory_session/attachments"},{"name":"issues","type":"array","description":"Replace the linked issues for this session.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/exploratory_session/issues"}],"intent":"Update an exploratory session","guidance":["edit a session (title, charter, timebox_duration, duration, owner, tags, ...)","Updates one or more fields of an exploratory session","Time fields (timebox_duration, duration, actual_duration) must be non-negative integers not exceeding 2147483647"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"]},{"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":"Updated rich-text HTML content of the log entry.","json_path":"/session_log/content"},{"name":"status","type":"string","values":["pass","fail","bug","retest","block","note"],"example":"fail","description":"Updated test result status.","json_path":"/session_log/status"},{"name":"elapsed_time","type":"integer","example":180,"description":"Updated elapsed time in seconds.","json_path":"/session_log/elapsed_time"},{"name":"defects","type":"array","description":"Replace the linked defects for this log entry.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/session_log/defects"}],"intent":"Update a session log entry","guidance":["Updates an existing log entry within an exploratory session","Rich-text HTML content is sanitised before storage"],"returns":["id","status","content","created_at","elapsed_time","session_id","updated_at"]},{"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,"description":"Name of the filter"},{"name":"entity","type":"string","required":true,"values":["testcase","testplan","testrun","exploratory_session","test_plan_test_case"],"description":"Entity type the filter applies to. The server's validator accepts five values (the\nlast two were missing here); an unlisted value returns 400 \"Invalid JSON data\"."},{"name":"is_project_level","type":"boolean","description":"Whether this filter is available at project level"},{"name":"filters","type":"object","description":"Filter criteria object containing the actual filter conditions"}],"intent":"Update a filter","guidance":["Update an existing saved filter with new configuration or filter criteria"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"]},{"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"},{"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 these are ids, not names).","json_path":"/test_result/status_id"},{"name":"description","type":"string","description":"Rich text description of the test result.","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's 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":"Update the latest test result","guidance":["update the LATEST result for a case in a run (partial, same body shape)","Update the most recent test result for a specific test case within a test run in a project","Optionally filter by configuration_id or mapping_id"],"returns":["id","name","byte_size","checksum","content_type","download_url","key","product_id","size","url"]},{"method":"POST","path":"/api/v1/projects/{project_id}/settings","mode":"write","entity":"project","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Update project settings","guidance":["Accepts an object with setting keys as properties and boolean values"]},{"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","example":10000},{"name":"report_type","type":"string","required":true,"values":["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","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_statu"},{"name":"title","type":"string","example":"Updated Title"},{"name":"description","type":"string","example":""},{"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 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`"},{"name":"report_creation_mode","type":"string","required":true,"values":["custom_report","system_generated"],"example":"custom_report"},{"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"},{"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":"Integer run ids \u2014 pairs with report_timeframe=specific_test_runs.","json_path":"/report_filters/test_runs"},{"name":"test_plans","type":"array","description":"Integer plan ids \u2014 pairs with report_timeframe=specific_test_plans.","json_path":"/report_filters/test_plans"},{"name":"test_cases","type":"string","description":"Case selection \u2014 pairs with report_timeframe=specific_test_cases. FOLDER-KEYED\n(see SelectionData), never a flat id list: the server resolves the selection\nthrough its folder map, so any other shape yields zero cases and saves an\nUNSCOPED, project-wide report at 200.\n\nSend all three keys. Folder ids are STRING keys; test-case ids are INTEGERS\n(the numeric `id`, not the TC-NNN identifier) \u2014 take bo","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":"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.","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 } }.","json_path":"/report_filters/test_plan_selection"},{"name":"builds","type":"array","json_path":"/report_filters/builds"},{"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\n`Test Run Summary`, `Test Run Detailed Report` and `Test Case Activity` \u2014 on\nthe other six report types the server never reads it and drops it silently.\nPersisted (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`."},{"name":"users","type":"array","json_path":"/mail_to/users"},{"name":"external_mails","type":"array","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\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"},{"name":"time","type":"string","example":"08:00","description":"24-hour HH:MM, UTC.","json_path":"/frequency_details/time"},{"name":"day","type":"string","example":"monday","description":"Required for weekly (lowercase day name) and monthly (integer 1-28).\nOmit for daily.","json_path":"/frequency_details/day"}],"intent":"Update a scheduled report","guidance":["FULL REPLACE, not a delta: omitted fields are nulled and dropping frequency cancels the schedule","Update the configuration of an existing scheduled report for a project"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"]},{"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":"Title of the shared step"},{"name":"folder_id","type":"integer","description":"ID of the shared-field folder to move the shared step into. Null moves it to the root (Unassigned)."},{"name":"shared_step_details","type":"array","required":true,"fields":[{"name":"id","type":"integer"},{"name":"step","type":"string"},{"name":"result","type":"string"},{"name":"order","type":"integer"},{"name":"test_data","type":"string"}]}],"intent":"Update a shared step","guidance":["title and shared_step_details are BOTH required, and the details array REPLACES the existing steps","Updates an existing shared step in a project"],"returns":["id","title"]},{"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"},{"name":"description","type":"string","json_path":"/test_plan/description"},{"name":"start_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/start_date"},{"name":"end_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/end_date"},{"name":"plan_status","type":"string","description":"Plan status. The list filter accepts new / started / completed.","json_path":"/test_plan/plan_status"},{"name":"owner","type":"integer","description":"Owner user id \u2014 resolve via the project users read first.","json_path":"/test_plan/owner"},{"name":"parent_plan_id","type":"integer","description":"Parent plan's INTEGER id. Set it to create (or re-parent into) a SUB-plan \u2014 the\nresult carries an STP-NNN identifier. Null/omitted means a top-level plan.","json_path":"/test_plan/parent_plan_id"},{"name":"tags","type":"array","json_path":"/test_plan/tags"},{"name":"reviewers","type":"array","description":"Reviewer user ids.","json_path":"/test_plan/reviewers"},{"name":"attachments","type":"array","description":"Attachment ids already uploaded via the attachments surface.","json_path":"/test_plan/attachments"},{"name":"custom_fields","type":"object","json_path":"/test_plan/custom_fields"},{"name":"issues","type":"array","description":"Linked tracker issues. Structured \u2014 NOT a flat array of keys.","fields":[{"name":"issue_id","type":"string"},{"name":"issue_type","type":"string"},{"name":"metadata","type":"object"}],"json_path":"/test_plan/issues"},{"name":"test_runs","type":"string","description":"The plan's linked test runs. Accepts EITHER an array of test-run integer ids, OR a\nbulk-selection object for \"every run matching this filter\". On update this REPLACES\nthe existing link set rather than appending.","json_path":"/test_plan/test_runs"}],"intent":"Update a test plan (or sub-plan)","guidance":["Update a plan by its INTEGER id","Takes the same body as create","so it also works on a sub-plan by its own id","clearing it promotes a sub-plan to a top-level plan","do that only when the user actually asked to move it in the hierarchy","Send only the fields you intend to change"]},{"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 AI-generated or AI-processed attachments","guidance":["upload a file as AI CONTEXT (restricted MIME types, smaller size cap) to seed test-case content","Files are stored in S3 with pre-signed URLs","File Storage: Files uploaded to S3 with pre-signed URLs (expires in ~26 minutes)"],"returns":["id","name","content_type","download_url","size","url"]},{"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 generic attachments to a project from rich text editor or other sources","guidance":["UPLOAD file blob(s) (multipart)","Uploads one or more files as generic attachments to a project","Files are stored in S3 and pre-signed URLs are returned for both viewing and downloading"],"returns":["id","name","content_type","download_url","size","url"]},{"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":"Upload one or more attachments to a test case","guidance":["Upload one or more attachments to a specific test case within a folder in a project"],"returns":["id","byte_size","content_type","filename"]},{"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 whose per-test-case / per-run / per-day breakdown is requested."},{"name":"p","type":"integer","example":1,"description":"Page number for paginated drill-down rows."},{"name":"per_page","type":"integer","example":50,"description":"Number of rows per page."}],"intent":"Fetch the User Workload drill-down for a single user","guidance":["User-Workload per-user execution breakdown","Only valid on a report whose type is User Workload Report","every other type returns 400 \"Drill-down is only available for User Workload Reports\""],"shape":"discovered","max_page_size":100,"paginated":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},"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)","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_v1"]},"attachment":{"entity":"attachment","title":"Attachments","aliases":["attachment","file","attachments","blob"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"attached to"},{"entity":"result","via":"attached to"}],"capabilities":["delete_test_case_attachment","get_test_case_attachments_v1","upload_a_i_attachments","upload_generic_attachments","upload_test_case_attachments_v1"]},"configuration":{"entity":"configuration","title":"Configurations","aliases":["config","configuration","environment","browser","os","device"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_run","via":"case status tracked against"}],"capabilities":["create_configuration_v1","get_configurations_v1"]},"custom_field":{"entity":"custom_field","title":"Custom fields & system fields","aliases":["custom field","custom fields","field","system field"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"extends"},{"entity":"result","via":"extends"}],"capabilities":["create_custom_field_dataset_option_v2","create_custom_field_dataset_v2","create_custom_field_definition_v2","delete_custom_field_dataset_option_v2","delete_custom_field_dataset_v2","delete_custom_field_definition_v2","get_custom_field_dataset_v2","get_custom_field_definition_v2","get_custom_field_values_v1","get_project_id_custom_fields_v2_v1","get_test_results_custom_fields_v1","list_custom_field_dataset_options_v2","list_workspace_fields_v2","update_custom_field_dataset_option_v2","update_custom_field_dataset_project_mapping_v2","update_custom_field_definition_v2"]},"dataset":{"entity":"dataset","title":"Dataset","aliases":["datasets","data-driven-data","dds"],"id_convention":"uuid","parents":["project"],"relations":[{"entity":"test_case","via":"drives"},{"entity":"variable","via":"has columns"}],"capabilities":["create_dataset","delete_dataset","get_dataset","get_dataset_linked_test_cases","get_dataset_variables","import_dataset_c_s_v","list_datasets","update_dataset"]},"duplicate":{"entity":"duplicate","title":"Duplicate recommendation (AI dedupe)","aliases":["duplicate","duplicates","dedupe","deduplication","duplicate recommendation","ai duplicates","redundant test cases"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"links the pair it believes are duplicates"}],"capabilities":["discard_duplicate_v1","get_dedupe_status_v1","get_duplicate_source_test_case_v1","get_duplicate_v1","list_duplicates_v1","resolve_duplicate_v1","search_duplicates_v1"]},"exploratory_session":{"entity":"exploratory_session","title":"Exploratory session","aliases":["session","exploratory","et-session"],"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_v1","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"]},"filter":{"entity":"filter","title":"Saved filter view","aliases":["filter","filters","saved view","saved 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"]},"folder":{"entity":"folder","title":"Folder","aliases":["dir","directory"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"folder","via":"nests under"},{"entity":"test_case","via":"organises"}],"capabilities":["copy_folder","create_bulk_test_cases_v1","create_root_folder_v1","create_sub_folder_v1","delete_folder_and_contents","get_folder_remove_summary","get_folder_tree_v1","get_root_folders_v1","list_folder_contents","move_folder_v1","rename_folder_v1","reorder_folders","undo_remove_folder"]},"project":{"entity":"project","title":"Project","aliases":["pr","workspace"],"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_v1","export_dashboard_analytics","get_active_test_runs_info_v1","get_automation_stats_v1","get_closed_test_runs_info_v1","get_closed_test_runs_split_v1","get_entity_filter_details_v1","get_issues_count_info_v1","get_project_by_integer_id_v1","get_project_settings","get_project_users_v2_v1","get_projects_basic_v1","get_projects_minify_v1","get_test_case_count_trend_v1","get_test_case_type_split_v1","list_projects_v1","search_project_entities","update_project_settings"]},"report":{"entity":"report","title":"Report","aliases":["reports","scheduled-report","schedule","analytics-report"],"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","delete_report_schedule","download_report","get_exploratory_session_summary_v1","get_report_attachment_url","get_report_detail","get_report_section_v1","get_report_summary_by_type","get_selected_report_testcases","list_schedules","send_report_email_now_v1","update_report","user_workload_drilldown"]},"result":{"entity":"result","title":"Result","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_v1","create_step_result_v1","create_test_result_for_test_case","delete_test_result_v1","get_test_results_for_test_case","update_latest_test_result_for_test_case"]},"shared_step":{"entity":"shared_step","title":"Shared step","aliases":["shared step","shared steps","reusable step","step template"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"reused by"}],"capabilities":["create_shared_step_v1","delete_shared_step_v1","get_shared_steps_by_i_d_v1","get_shared_steps_v1","update_shared_step_v1"]},"tag":{"entity":"tag","title":"Tag","aliases":["tags","label","labels"],"id_convention":"name","parents":["project"],"relations":[{"entity":"test_case","via":"labels"},{"entity":"test_run","via":"labels"},{"entity":"test_plan","via":"labels"}],"capabilities":["bulk_edit_test_cases_v2","get_test_case_tags","list_test_case_tags_v1","merge_tags_v1","search_group_tags","search_test_case_tags"]},"test_case":{"entity":"test_case","title":"Test case","aliases":["tc","case","test case"],"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":["bulk_archive_test_cases_by_project","bulk_copy_test_cases","bulk_delete_test_cases","bulk_edit_test_cases","bulk_edit_test_cases_in_test_run","bulk_move_test_cases","bulk_retrieve_test_cases","create_test_case_v1","create_test_cases","edit_test_case_partial_v1","edit_test_case_v1","get_archived_test_cases","get_system_field_values_v1","get_test_case_by_integer_id_v1","get_test_case_detail","get_test_cases_v1","list_folder_test_cases_v1","reorder_test_cases_by_folder_v1","search_project_entities_v2","search_test_cases_by_folder_v1"]},"test_plan":{"entity":"test_plan","title":"Test plan (and sub-plan)","aliases":["tp","plan","milestone","sub test plan","subplan","stp"],"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_v1","bulk_retrieve_test_plans_v1","clone_test_plan_v1","count_test_plan_test_runs_v1","create_test_plan_v1","delete_test_plan_v1","get_archived_test_plan_count_v1","get_test_plan_by_integer_id_v1","get_test_plan_execution_trend_v1","get_test_plan_linked_sessions_chart_data_v1","get_test_plan_test_runs_v1","list_archived_test_plans_v1","list_test_plans_v1","update_test_plan_v1"]},"test_run":{"entity":"test_run","title":"Test run","aliases":["tr","run","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":["assign_test_run_owner","bulk_assign_test_cases_to_test_run","clone_test_run","close_test_run_v1","count_run_selection_v1","create_test_run_v1","delete_v1_test_run","edit_test_run","get_closed_test_runs","get_test_cases_for_v1_test_run","get_test_run_by_integer_id_v1","get_test_run_cases_v1","get_test_runs_form_fields_v1","get_test_runs_selection","get_test_runs_v1"]},"user":{"entity":"user","title":"User","aliases":["users","member","assignee","owner"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"project","via":"member of"},{"entity":"test_run","via":"assigned to"},{"entity":"test_case","via":"owns"}],"capabilities":["get_group_users_v2_v1","search_project_users"]},"version":{"entity":"version","title":"Test case version","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":["get_test_case_histories_v1","get_test_case_history_diff_v1","get_test_case_history_v1","restore_history_v1"]}}}}} \ No newline at end of file diff --git a/package.json b/package.json index cad0a86..4f01ec5 100644 --- a/package.json +++ b/package.json @@ -21,8 +21,7 @@ "browserstack-mcp-server": "dist/index.js" }, "files": [ - "dist", - "capability-index.json" + "dist" ], "keywords": [ "mcp", diff --git a/src/lib/tm-base-url.ts b/src/lib/tm-base-url.ts index 475822a..de85f0d 100644 --- a/src/lib/tm-base-url.ts +++ b/src/lib/tm-base-url.ts @@ -17,8 +17,8 @@ export const TM_BASE_URLS = [ /** * A TEST-HARNESS AFFORDANCE. Point region discovery at a non-production environment. * - * Named and parsed after `CAPABILITY_REGISTRY_BASE_URLS`, plural because the probe loop takes - * a list. It REPLACES the built-in list rather than extending it — appending would leave the + * Plural because the probe loop takes a list. It REPLACES the built-in list rather than + * extending it — appending would leave the * production hosts probed first, which is the whole problem it exists to avoid: preprod-only * credentials 401 against production, and a 401 can send the model off to retry with a * different tool, so a tool-selection measurement stops meaning what it says. diff --git a/src/server-factory.ts b/src/server-factory.ts index 0633434..da3f3af 100644 --- a/src/server-factory.ts +++ b/src/server-factory.ts @@ -20,7 +20,6 @@ import addBuildInsightsTools from "./tools/build-insights.js"; import { setupOnInitialized } from "./oninitialized.js"; import { BrowserStackConfig } from "./lib/types.js"; import addRCATools from "./tools/rca-agent.js"; -import addCapabilityRegistryTools from "./tools/capability-registry/register.js"; import addAskBrowserstackAITool from "./tools/ask-browserstack/register.js"; /** @@ -63,10 +62,6 @@ export class BrowserStackMcpServer { addSelfHealTools, addBuildInsightsTools, addRCATools, - // Driven by a prebuilt index rather than hand-written per endpoint. Registers - // nothing (and logs why) when the artifact is absent, so a packaging problem cannot - // take the other products' tools down with it. - addCapabilityRegistryTools, // Hands a plain-language task to BrowserStack's agent and relays its mid-run // permission asks back to this client, so a write can be confirmed by the human // sitting in front of it rather than refused for want of anyone to ask. diff --git a/src/tools/capability-registry/bind.ts b/src/tools/capability-registry/bind.ts deleted file mode 100644 index 1204542..0000000 --- a/src/tools/capability-registry/bind.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Turn grouped caller arguments into a path, a query and a body. - * - * Arguments arrive GROUPED — {path_params, query, body} — because spec parameter names - * collide across locations: four tm operations declare one name in two places (`bulk-move` - * has `folder_id` as both a path parameter and a body field). A flat map cannot say which - * one is meant, which is exactly why the Python side used to rename body fields `body_*`. - * Grouping removes the collision AND the rename, so a caller sends the spec's own names. - */ - -import { InvocationError } from "./index-loader.js"; -import { Capability, WireParam } from "./types.js"; - -export interface GroupedArguments { - path_params?: Record; - query?: Record; - body?: Record; -} - -export interface BoundRequest { - path: string; - query: Record; - body?: Record; -} - -/** - * Check one argument against its declared schema, raising a caller-safe error. - * - * Type checking is also the injection defence for path parameters: most of tm's 278 path - * parameters are `type: integer`, so a traversal attempt like `../../admin-v2` fails here - * rather than being encoded into a URL. - */ -export function coerce(value: unknown, param: WireParam): unknown { - const expected = param.type; - 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. - if (expected === "object" && (typeof value !== "object" || value === null || Array.isArray(value))) { - throw new InvocationError(`'${param.name}' must be an object`); - } - if (expected === "array" && !Array.isArray(value)) { - throw new InvocationError(`'${param.name}' must be a list`); - } - return value; - } - if (expected === "integer" || expected === "number") { - const parsed = Number(String(value).trim()); - if (!Number.isFinite(parsed)) { - throw new InvocationError(`'${param.name}' must be a number`); - } - return expected === "integer" ? Math.trunc(parsed) : parsed; - } - if (expected === "boolean") { - if (typeof value === "boolean") return value; - const text = String(value).trim().toLowerCase(); - if (["true", "1", "yes"].includes(text)) return true; - if (["false", "0", "no"].includes(text)) return false; - throw new InvocationError(`'${param.name}' must be true or false`); - } - const text = String(value); - if (param.values && param.values.length > 0) { - const allowed = param.values.map((v) => String(v)); - if (!allowed.includes(text)) { - throw new InvocationError(`'${param.name}' must be one of: ${allowed.join(", ")}`); - } - } - return text; -} - -/** Place a value at a JSON-pointer-ish path, creating the objects on the way. */ -function place(root: Record, pointer: string, value: unknown): void { - const segments = pointer.split("/").filter((segment) => segment !== ""); - let cursor = root; - for (const segment of segments.slice(0, -1)) { - const next = cursor[segment]; - if (typeof next !== "object" || next === null || Array.isArray(next)) { - cursor[segment] = {}; - } - cursor = cursor[segment] as Record; - } - cursor[segments[segments.length - 1]] = value; -} - -const GROUPS: { group: keyof GroupedArguments; declared: keyof Capability }[] = [ - { group: "path_params", declared: "path_params" }, - { group: "query", declared: "query" }, - { group: "body", declared: "body" }, -]; - -export function bind(capability: Capability, args: GroupedArguments): BoundRequest { - let path = capability.path; - const query: Record = {}; - const body: Record = {}; - - for (const { group, declared } of GROUPS) { - const supplied = args[group] || {}; - if (typeof supplied !== "object" || supplied === null || Array.isArray(supplied)) { - throw new InvocationError(`${group} must be an object of name -> value`); - } - const params = (capability[declared] as WireParam[] | undefined) || []; - const byName = new Map(params.map((param) => [param.name, param])); - - // Unknown arguments are an error rather than being dropped: silently ignoring a - // misspelled filter would return a larger result set that looks like a correct answer. - const unknown = Object.keys(supplied).filter((name) => !byName.has(name)); - if (unknown.length > 0) { - throw new InvocationError( - `unknown ${group}: ${unknown.sort().join(", ")}. accepted: ` + - `${[...byName.keys()].sort().join(", ") || "none"}`, - ); - } - - for (const [name, raw] of Object.entries(supplied)) { - const param = byName.get(name)!; - const value = coerce(raw, param); - if (group === "path_params") { - // Encode with nothing exempt: a `/` inside a path value would otherwise rewrite the - // route. Schema checking already stops this for integer ids; this covers strings. - path = path.replaceAll(`{${name}}`, encodeURIComponent(String(value))); - } else if (group === "body") { - place(body, param.json_path || `/${name}`, value); - } else { - query[name] = value; - } - } - } - - // `required` is enforced for BODY as well as path. It was path-only on the Python side at - // first, so a missing required body field passed silently and the product answered with a - // 4xx that read like the caller's fault. - const missing: string[] = []; - for (const { group, declared } of GROUPS) { - if (group === "query") continue; - const supplied = args[group] || {}; - for (const param of (capability[declared] as WireParam[] | undefined) || []) { - if (param.required && !(param.name in supplied)) missing.push(param.name); - } - } - if (missing.length > 0) { - throw new InvocationError( - `missing required parameter(s): ${missing.sort().join(", ")}`, - ); - } - - const leftover = path.match(/\{[a-z_]+\}/gi); - if (leftover) { - throw new InvocationError( - `path placeholder(s) not supplied: ${leftover.join(", ")}`, - ); - } - return { path, query, body: Object.keys(body).length > 0 ? body : undefined }; -} diff --git a/src/tools/capability-registry/config.ts b/src/tools/capability-registry/config.ts deleted file mode 100644 index fd47404..0000000 --- a/src/tools/capability-registry/config.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * Where the index comes from, and where each product lives. - * - * `base_url` is deliberately NOT in the artifact — it is environment- AND account-specific, - * so the same artifact ships everywhere and the host is resolved here. - */ - -import { existsSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { dirname, join, resolve } from "node:path"; - -import { BrowserStackConfig } from "../../lib/types.js"; -import { getTMBaseURL } from "../../lib/tm-base-url.js"; -import { InvocationError } from "./index-loader.js"; - -/** - * Resolve a product's host. - * - * tm is REGION-SPECIFIC and the package already discovers it: `getTMBaseURL` probes - * test-management{,-eu,-in}.browserstack.com with the caller's credentials and returns the - * one their account lives on. Hardcoding the default host instead would fail every EU and - * IN account on every call, which is what an earlier version of this file did. - * - * An explicit override still wins, because that is how a non-production environment is - * reached — no preprod host is or should be compiled in. - * - * Other products are NOT guessed. A wrong host fails as a DNS error or a 404 that reads - * like the caller's problem; refusing names the real cause. Add one here only once its host - * is known rather than inferred from a naming pattern. - */ -/** - * The environment this DEPLOYMENT points at, e.g. "preprod". - * - * Process-level on purpose, and the distinction from region matters: an environment is a - * property of the deployment (this instance talks to preprod), whereas a REGION is a - * property of the account (this user's data lives in EU). That is why region discovery is - * per request and never cached under REMOTE_MCP, while the environment is read once here. - */ -export function selectedEnvironment(): string { - return (process.env.CAPABILITY_REGISTRY_ENV || "").trim(); -} - -/** product -> env -> host, the analogue of Atlas's `harness.extra_environments`. */ -function environmentMap(): Record> { - const raw = process.env.CAPABILITY_REGISTRY_BASE_URLS; - if (!raw) return {}; - try { - const parsed = JSON.parse(raw); - return parsed && typeof parsed === "object" ? parsed : {}; - } catch { - // A malformed map must not silently mean "no override" — that would send a preprod - // deployment at production. - throw new InvocationError( - "CAPABILITY_REGISTRY_BASE_URLS is not valid JSON; expected {product: {env: url}}", - ); - } -} - -/** - * Resolve a product's host, mirroring Atlas's precedence. - * - * Atlas resolves: an explicit per-session override, then the host for the session's - * environment (`harness.extra_environments[env][product]`), then the profile's own - * `base_url`. The same rungs, in the same order: - * - * 1. CAPABILITY_REGISTRY_BASE_URL_ explicit, environment-agnostic - * 2. CAPABILITY_REGISTRY_BASE_URL__ this environment's host - * 3. CAPABILITY_REGISTRY_BASE_URLS {product:{env:url}} the same, as one map - * 4. the harness-declared host, carried in the artifact - * 5. product-specific discovery (tm is region-sharded) - * 6. refuse, by name - * - * 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. - */ -export async function resolveBaseUrl( - product: string, - config: BrowserStackConfig, - harnessBaseUrl?: string, -): Promise { - const key = product.toUpperCase(); - const environment = selectedEnvironment(); - - const explicit = process.env[`CAPABILITY_REGISTRY_BASE_URL_${key}`]; - if (explicit) return explicit.replace(/\/$/, ""); - - if (environment) { - const suffixed = process.env[ - `CAPABILITY_REGISTRY_BASE_URL_${key}_${environment.toUpperCase()}` - ]; - if (suffixed) return suffixed.replace(/\/$/, ""); - const mapped = environmentMap()[product]?.[environment]; - if (mapped) return String(mapped).replace(/\/$/, ""); - // An environment was named and nothing defines its host. Falling back to the harness - // default here would send a preprod deployment at production, silently. - if (harnessBaseUrl || product === "tm") { - throw new InvocationError( - `environment '${environment}' has no host for product '${product}'. Set ` + - `CAPABILITY_REGISTRY_BASE_URL_${key}_${environment.toUpperCase()} or add it to ` + - `CAPABILITY_REGISTRY_BASE_URLS.`, - ); - } - } - - // NOTE the sharp edge: a harness-declared host is one fixed origin, so declaring one for a - // region-sharded product would send EU and IN accounts to the wrong region. tm declares - // none for exactly that reason and falls through to discovery. - if (harnessBaseUrl) return harnessBaseUrl.replace(/\/$/, ""); - - if (product === "tm") return (await getTMBaseURL(config)).replace(/\/$/, ""); - - throw new InvocationError( - `no host is configured for product '${product}': the harness declares none and there ` + - `is no override. Set CAPABILITY_REGISTRY_BASE_URL_${key}.`, - ); -} - -/** - * Locate the artifact. Explicit env wins; otherwise look beside the compiled module and - * then at the package root, because `tsc` compiles TS and does not copy JSON into `dist`. - */ -export function indexPath(): string | undefined { - const configured = process.env.CAPABILITY_REGISTRY_INDEX; - if (configured) return existsSync(configured) ? resolve(configured) : undefined; - - const here = dirname(fileURLToPath(import.meta.url)); - const candidates = [ - join(here, "registry-index.json"), - join(here, "..", "..", "..", "capability-index.json"), // dist/ or src/ -> package root - join(here, "..", "..", "..", "..", "capability-index.json"), - ]; - return candidates.find((candidate) => existsSync(candidate)); -} - -/** Off by default is wrong for a shipped feature, but a kill switch is not. */ -export function isEnabled(): boolean { - return (process.env.CAPABILITY_REGISTRY_DISABLED || "").toLowerCase() !== "true"; -} diff --git a/src/tools/capability-registry/egress.ts b/src/tools/capability-registry/egress.ts deleted file mode 100644 index f92531c..0000000 --- a/src/tools/capability-registry/egress.ts +++ /dev/null @@ -1,87 +0,0 @@ -/** - * The outbound call: auth, attribution, and one HTTP request. - * - * AUTH IS THE CALLER'S OWN CREDENTIALS, FORWARDED. Every /api/v1 route accepts - * `Api-Token: :` and validates it against IAAM OAuth2 v2 — the same - * identity resolution a minted bearer token produces, one hop earlier. Verified in - * browserstack/teststack: the 59 v1 controllers inheriting ApplicationApiController resolve - * it in `current_user`, the 5 inheriting Api::V1::ApiController in `authenticate_token`. - * - * Note HTTP Basic is NOT usable on /api/v1 — `authenticate_with_authorization_header` never - * reaches the Basic path, so only those 5 controllers accept it. Api-Token is the one that - * works for the whole surface. - */ - -import { InvocationError } from "./index-loader.js"; - -export interface Credentials { - username: string; - accessKey: string; -} - -export interface HttpResponse { - status: number; - body: unknown; - error?: string; -} - -export type Transport = ( - method: string, - url: string, - headers: Record, - query: Record, - body?: unknown, -) => Promise; - -export function authHeaders(credentials: Credentials): Record { - if (!credentials?.username || !credentials?.accessKey) { - // Refusing here beats sending unauthenticated and surfacing the product's 401, which - // reads like the user's problem when it is our missing configuration. - throw new InvocationError( - "this request is not authenticated: BrowserStack username and access key are required", - ); - } - return { - "Api-Token": `${credentials.username}:${credentials.accessKey}`, - // Attribution, so the downstream service can see the call came from an agent. - "request-source": "ai-chatbot", - "Content-Type": "application/json", - }; -} - -/** A fetch-based transport. Redirects are NOT followed. */ -export function fetchTransport(timeoutMs = 45_000): Transport { - return async (method, url, headers, query, body) => { - const target = new URL(url); - for (const [key, value] of Object.entries(query || {})) { - if (value !== undefined && value !== null) target.searchParams.set(key, String(value)); - } - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - const response = await fetch(target.toString(), { - method, - headers, - // Only send a body when there IS one: a literal `null` payload with a JSON - // content-type is rejected by several endpoints. - body: body === undefined ? undefined : JSON.stringify(body), - // A redirect from an authenticated API is usually a login bounce, and following it - // turns a clear 401/302 into a 200 carrying an HTML sign-in page — which the - // resolver would then read as an empty result set rather than a failure. - redirect: "manual", - signal: controller.signal, - }); - let parsed: unknown = null; - const contentType = response.headers.get("content-type") || ""; - if (contentType.includes("json")) { - parsed = await response.json().catch(() => null); - } - return { status: response.status, body: parsed }; - } catch { - // Upstream detail stays out of the reply; the resolver treats status 0 as a failed call. - return { status: 0, body: null, error: "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 deleted file mode 100644 index 6bada70..0000000 --- a/src/tools/capability-registry/index-loader.ts +++ /dev/null @@ -1,88 +0,0 @@ -/** - * Load the index artifact and expose the two lookups the tools need. - */ - -import { readFileSync } from "node:fs"; -import { Capability, RegistryIndex, SUPPORTED_SCHEMA_VERSION } from "./types.js"; - -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 function endpointKey(method: string, path: string): string { - return `${(method || "").trim().toUpperCase()} ${(path || "").trim()}`; -} - -export class CapabilityRegistry { - readonly index: RegistryIndex; - /** product -> "METHOD /path" -> capability */ - private readonly byEndpoint = new Map>(); - - constructor(index: RegistryIndex) { - if (index?.schema_version !== SUPPORTED_SCHEMA_VERSION) { - // Refuse rather than best-effort read: a shape change the generator announced is - // exactly the case where guessing produces silently wrong tool output. - throw new IndexError( - `unsupported index schema_version ${index?.schema_version}; this build reads ` + - `${SUPPORTED_SCHEMA_VERSION}. Rebuild the artifact or update the server.`, - ); - } - if (!index.products || Object.keys(index.products).length === 0) { - throw new IndexError("index contains no products"); - } - this.index = index; - for (const [product, bundle] of Object.entries(index.products)) { - const lookup = new Map(); - for (const capability of bundle.capabilities) { - lookup.set(endpointKey(capability.method, capability.path), capability); - } - this.byEndpoint.set(product, lookup); - } - } - - static fromFile(file: string): CapabilityRegistry { - return new CapabilityRegistry(JSON.parse(readFileSync(file, "utf8")) as RegistryIndex); - } - - get buildId(): string { - return this.index.build_id; - } - - productNames(): string[] { - return Object.keys(this.index.products).sort(); - } - - /** - * Find a capability by the endpoint it exposes — the published handle. - * - * The endpoint is what searchCapability returns, so it is the only thing a caller can - * hold. `unknown_endpoint` is a defined outcome rather than a generic failure: a caller - * working from stale search output needs to know to search again, not to retry. - */ - byEndpointLookup(method: string, path: string, product?: string): { - product: string; - capability: Capability; - } { - const key = endpointKey(method, path); - const matches: { product: string; capability: Capability }[] = []; - for (const [name, lookup] of this.byEndpoint) { - if (product && name !== product) continue; - const capability = lookup.get(key); - if (capability) matches.push({ product: name, capability }); - } - if (matches.length === 0) { - throw new InvocationError( - `unknown_endpoint: ${key}. Search again — send \`method\` and \`path\` exactly as ` + - `searchCapability returned them, placeholders included.`, - ); - } - if (matches.length > 1 && !product) { - const owners = matches.map((m) => m.product).sort().join(", "); - throw new InvocationError( - `${key} exists in several products (${owners}); pass product`, - ); - } - return matches[0]; - } -} diff --git a/src/tools/capability-registry/register.ts b/src/tools/capability-registry/register.ts deleted file mode 100644 index 23adbaa..0000000 --- a/src/tools/capability-registry/register.ts +++ /dev/null @@ -1,271 +0,0 @@ -/** - * The tool surface: four discovery tools plus ONE invoke tool. - * - * ONE invoke tool means one set of MCP annotations, so they describe the whole surface - * honestly: it can write (not read-only) and it can never delete, because destructive - * endpoints are refused before binding. Write consent therefore rests on `user_permission` - * enforced HERE rather than on a client-side hint — which is the one thing a separate - * read/write tool pair was buying. - */ - -import { McpServer, RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; -import { z } from "zod"; - -import logger from "../../logger.js"; -import { trackMCP } from "../../lib/instrumentation.js"; -import { BrowserStackConfig } from "../../lib/types.js"; -import { GroupedArguments } from "./bind.js"; -import { indexPath, isEnabled, resolveBaseUrl } from "./config.js"; -import { Credentials, Transport, fetchTransport } from "./egress.js"; -import { CapabilityRegistry, InvocationError } from "./index-loader.js"; -import { invoke } from "./resolve.js"; -import { searchCapabilities } from "./search.js"; -import { Mode } from "./types.js"; - -export const PERMISSION_VALUES = ["not_asked", "granted", "denied"] as const; - -export interface RegistryDeps { - registry: CapabilityRegistry; - /** - * 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; - credentialsFor: () => Credentials; - transport?: Transport; -} - -/** - * The tool-adder the server factory calls. - * - * Registers NOTHING when the artifact is absent or unreadable, rather than throwing: a - * missing index is a packaging problem, and taking the whole MCP server down with it would - * remove every other product's tools too. The reason is logged so it is not silent. - */ -export function addCapabilityRegistryToolsFromConfig( - server: McpServer, - config: BrowserStackConfig, -): Record { - if (!isEnabled()) { - logger.info("capability registry disabled by CAPABILITY_REGISTRY_DISABLED"); - return {}; - } - const file = indexPath(); - if (!file) { - logger.warn( - "capability registry index not found; its tools are not registered. Set " + - "CAPABILITY_REGISTRY_INDEX or ship capability-index.json at the package root.", - ); - return {}; - } - let registry: CapabilityRegistry; - try { - registry = CapabilityRegistry.fromFile(file); - } catch (error) { - logger.error( - "capability registry index at %s is unusable: %s", - file, error instanceof Error ? error.message : String(error), - ); - return {}; - } - logger.info( - "capability registry loaded: build %s, %d product(s)", - registry.buildId, registry.productNames().length, - ); - return addCapabilityRegistryTools(server, { - registry, - baseUrlFor: (product) => - resolveBaseUrl(product, config, registry.index.products[product]?.base_url), - // Read per call, not captured: the remote server rebuilds config per session, so a - // captured credential would outlive the session it belongs to. - credentialsFor: () => ({ - username: config["browserstack-username"], - accessKey: config["browserstack-access-key"], - }), - }, config); -} - -function ok(payload: unknown): CallToolResult { - return { content: [{ type: "text", text: JSON.stringify(payload) }] }; -} - -function failed(message: string): CallToolResult { - return { content: [{ type: "text", text: JSON.stringify({ ok: false, error: message }) }], isError: true }; -} - -export function addCapabilityRegistryTools( - server: McpServer, - deps: RegistryDeps, - config?: BrowserStackConfig, -): Record { - const { registry } = deps; - const transport = deps.transport || fetchTransport(); - const tools: Record = {}; - - /** Instrumentation in the house style, and never fatal to the call it wraps. */ - const track = (name: string) => { - try { - trackMCP(name, server.server.getClientVersion()!, undefined, config); - } catch { - // Telemetry must not decide whether a tool call succeeds. - } - }; - - tools.listProducts = server.tool( - "listProducts", - "List the BrowserStack products this surface can reach, with a one-line summary each. " + - "Start here when you do not know which product a task belongs to.", - {}, - async () => { - track("listProducts"); - return ok({ - build_id: registry.buildId, - products: registry.productNames().map((name) => ({ - name, summary: registry.index.products[name].summary, - })), - }); - }, - ); - - tools.listEntities = server.tool( - "listEntities", - "List the entities a product models (test case, folder, test plan, …). Use it to scope " + - "searchCapability, or to find the entity name describeEntity wants.", - { product: z.string().describe("Product name from listProducts.") }, - async ({ product }) => { - track("listEntities"); - const bundle = registry.index.products[product]; - if (!bundle) return failed(`unknown product '${product}'`); - return ok({ product, entities: Object.keys(bundle.entities).sort() }); - }, - ); - - tools.describeEntity = server.tool( - "describeEntity", - "Describe one entity: what it is, what identifies it, what it relates to, and the " + - "vocabulary the product uses for it. Read this before filtering or writing, because " + - "ids and field values usually have to be resolved first.", - { - product: z.string().describe("Product name from listProducts."), - entity: z.string().describe("Entity name from listEntities."), - }, - async ({ product, entity }) => { - track("describeEntity"); - const bundle = registry.index.products[product]; - if (!bundle) return failed(`unknown product '${product}'`); - const doc = bundle.entities[entity]; - if (!doc) { - return failed( - `unknown entity '${entity}' in ${product}; known: ${Object.keys(bundle.entities).sort().join(", ")}`, - ); - } - return ok({ product, entity, ...doc }); - }, - ); - - tools.searchCapability = server.tool( - "searchCapability", - "Find endpoints this surface can call, by plain language, optionally narrowed to one " + - "entity, product or mode. Each result carries the endpoint's `method` and `path` plus " + - "its parameters grouped into path_params / query / body under the spec's own names — " + - "pass them straight back to invokeEndpoint, no renaming. `guidance` is how to call it " + - "correctly; `mode` tells you whether it writes. Results are ranked and capped, and " + - "`truncated` says when more matched. Search before invoking.", - { - query: z.string().describe("What you are trying to do, in plain language."), - entity: z.string().optional().describe("Restrict to one entity (see listEntities)."), - product: z.string().optional().describe("Restrict to one product."), - mode: z.enum(["read", "write", "destructive"]).optional() - .describe("Restrict to reads or writes. Omit to let the query decide."), - limit: z.number().optional().describe("Max results (default 8)."), - }, - async ({ query, entity, product, mode, limit }) => { - track("searchCapability"); - return ok({ - build_id: registry.buildId, - ...searchCapabilities(registry.index.products, query, { - entity, product, mode: mode as Mode | undefined, limit, - }), - }); - }, - ); - - tools.invokeEndpoint = server.tool( - "invokeEndpoint", - "Call an endpoint returned by searchCapability. Pass `method` and `path` exactly as " + - "given, with arguments grouped into path_params / query / body under the spec's own " + - "names. Paging is handled for you — a read is complete unless `complete` is false; use " + - "order_by (prefix '-' to reverse) and top_n to sort and trim rather than fetching " + - "everything. If the endpoint's mode is 'write' you MUST ask the user first, then " + - "resend with user_permission='granted' and a change_summary; both are recorded. " + - "Endpoints 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.", - { - method: z.string().describe("HTTP method, exactly as searchCapability returned it."), - path: z.string().describe("Path with {placeholders} intact, exactly as returned."), - path_params: z.record(z.string(), z.any()).optional().describe("Values for the {placeholders}."), - query: z.record(z.string(), z.any()).optional().describe("Query parameters."), - body: z.record(z.string(), z.any()).optional().describe("Body fields, under the spec's names."), - product: z.string().optional().describe("Required only if two products share the endpoint."), - user_permission: z.enum(PERMISSION_VALUES).optional() - .describe("Set to 'granted' only after the user has confirmed a write."), - change_summary: z.string().optional().describe("What will change. Required for writes."), - }, - async (input): Promise => { - track("invokeEndpoint"); - try { - const { product, capability } = registry.byEndpointLookup( - input.method, input.path, input.product, - ); - const args: GroupedArguments = { - path_params: input.path_params, query: input.query, body: input.body, - }; - - if (capability.mode === "destructive") { - // Refused before binding, so consent is never sought for something that cannot run. - return failed( - `${input.method} ${input.path} is a destructive operation and is not available ` + - `through this surface`, - ); - } - - if (capability.mode === "write") { - const permission = input.user_permission || "not_asked"; - // PARAMETERS ARE VALIDATED BEFORE PERMISSION IS DEMANDED. The gate used to run - // first, so a caller with a typo'd parameter was told "ask the user to confirm this - // change", went back to the human for approval, and only then learned the parameter - // was wrong. A dry bind costs nothing and cannot mutate. - const { bind } = await import("./bind.js"); - bind(capability, args); - if (permission !== "granted") { - // Catches the careless path, not the adversarial one: the model fills this field - // in, so it is an audit record and a speed bump, never authorisation. - return failed( - "refused: this endpoint changes data — ask the user to confirm, then retry " + - "with user_permission='granted' and a change_summary", - ); - } - if (!(input.change_summary || "").trim()) { - return failed("change_summary is required: state what will change"); - } - } - - const result = await invoke( - capability, args, await deps.baseUrlFor(product), deps.credentialsFor(), transport, - - ); - return ok(result); - } catch (error) { - if (error instanceof InvocationError) return failed(error.message); - logger.error("invokeEndpoint failed: %s", error instanceof Error ? error.message : String(error)); - return failed("that endpoint could not be invoked"); - } - }, - ); - - return tools; -} - -export default addCapabilityRegistryToolsFromConfig; diff --git a/src/tools/capability-registry/resolve.ts b/src/tools/capability-registry/resolve.ts deleted file mode 100644 index 448e670..0000000 --- a/src/tools/capability-registry/resolve.ts +++ /dev/null @@ -1,80 +0,0 @@ -/** - * Invoke one endpoint and hand back what the product said. - * - * NO POST-PROCESSING, BY DECISION. There used to be row extraction by shape, a `returns` - * allowlist, a scalars-only filter for undeclared schemas, item counting, ordering, trimming, - * and guards that reported an empty projection as a registration defect. Every one of them - * was a place where we could be wrong ABOUT a correct answer — and each time we were, the - * caller saw a confident empty result rather than an error. The product's response is the - * answer; this module's job is to get it and return it. - * - * ONE REQUEST, ONE RESPONSE. Paging is therefore the caller's, which is why `p` and the - * page-size parameter are published for paginated endpoints (see `project.py::_is_public`). - * Hiding them made sense only while this module walked the pages itself. - */ - -import { bind, GroupedArguments } from "./bind.js"; -import { authHeaders, Credentials, Transport } from "./egress.js"; -import { InvocationError } from "./index-loader.js"; -import { Capability } from "./types.js"; - -export interface InvokeResult { - /** The product answered 2xx. Nothing else decides this. */ - ok: boolean; - /** - * Whether this response is the whole answer. - * - * False when the envelope itself says there is another page (`info.next`), so a caller - * knows to ask for one rather than assuming it has everything. This is a peek at one - * declared field, not a reshaping of the body. - */ - completed: boolean; - /** The product's status, and its body exactly as sent. */ - http_response: { - status: number; - body: unknown; - /** Only when there was no response at all to speak for itself. */ - error?: string; - }; -} - -function hasNextPage(body: unknown): boolean { - if (typeof body !== "object" || body === null || Array.isArray(body)) return false; - const info = (body as Record).info; - if (typeof info !== "object" || info === null) return false; - const next = (info as Record).next; - return next !== null && next !== undefined && next !== false; -} - -export async function invoke( - capability: Capability, - args: GroupedArguments, - baseUrl: string, - credentials: Credentials, - transport: Transport, -): Promise { - if (!baseUrl) throw new InvocationError("no base URL is configured for that product"); - const bound = bind(capability, args); - const headers = authHeaders(credentials); - - const response = await transport( - capability.method, - `${baseUrl.replace(/\/$/, "")}${bound.path}`, - headers, - bound.query, - bound.body, - ); - - const ok = response.status >= 200 && response.status < 300; - return { - ok, - completed: ok && !hasNextPage(response.body), - http_response: { - status: response.status, - body: response.body, - ...(response.status === 0 - ? { error: response.error || "the product could not be reached" } - : {}), - }, - }; -} diff --git a/src/tools/capability-registry/search.ts b/src/tools/capability-registry/search.ts deleted file mode 100644 index 8dbdd7e..0000000 --- a/src/tools/capability-registry/search.ts +++ /dev/null @@ -1,154 +0,0 @@ -/** - * Ranking capabilities against a plain-language query. - * - * Ported from the Python `discover._score`, including the two properties that were each - * fixed after a live mis-ranking: - * - * * PENALTIES REORDER, THEY DO NOT EXCLUDE. `matched` is the pre-penalty term score and is - * what decides inclusion; `ranked` carries the preferences. Conflating them dropped 40 - * legitimate matches outright, because a cardinality penalty took an otherwise-valid - * score to zero and the caller saw "no such capability". - * * CARDINALITY. A "list" query answered by a single-record getter sends the caller to a - * capability needing an id it cannot possibly have yet. - */ - -import { Capability, EntityDoc, Mode, ProductIndex } from "./types.js"; - -const WORD = /[a-z0-9_]+/g; - -const STOPWORDS = new Set([ - "a", "an", "and", "are", "as", "at", "be", "by", "can", "do", "for", "from", - "how", "i", "in", "is", "it", "me", "my", "of", "on", "or", - "that", "the", "to", "want", "what", "which", "with", "you", -]); - -// Verbs that reveal what the caller means to DO. A preference, not a filter — an explicit -// `mode` argument is the filter. -const READ_VERBS = new Set(["list", "get", "show", "find", "fetch", "read", "count", - "search", "view", "which", "how"]); -const WRITE_VERBS = new Set(["create", "add", "update", "edit", "delete", "remove", "move", - "copy", "archive", "assign", "restore", "reorder", "bulk", "set", "upload", "import", - "clone"]); - -// Words that mean "give me many", which is what makes a single-record getter the wrong answer. -const PLURAL_INTENT = new Set(["list", "all", "every", "many", "count", "search", "find", - "which", "each"]); - -/** Query/haystack terms. Verbs are deliberately NOT stopwords — they carry the intent. */ -export function terms(text: string | undefined): string[] { - return [...((text || "").toLowerCase().matchAll(WORD))] - .map((match) => match[0]) - .filter((word) => !STOPWORDS.has(word)); -} - -export function modeHint(query: string | undefined): "" | Mode { - const words = new Set(terms(query)); - const wantsWrite = [...words].some((word) => WRITE_VERBS.has(word)); - if (wantsWrite) return "write"; - const wantsRead = [...words].some((word) => READ_VERBS.has(word)); - return wantsRead ? "read" : ""; -} - -export function wantsCollection(query: string | undefined): boolean { - return [...((query || "").toLowerCase().matchAll(WORD))] - .some((match) => PLURAL_INTENT.has(match[0])); -} - -/** - * True when a capability answers with many records rather than one. - * - * Pagination is the reliable signal — a paged operation is a listing by construction. The - * plural terminal path segment is a weaker fallback for unpaged collections. (The Python - * side used the capability NAME here; the artifact publishes no name, and the path's own - * terminal noun carries the same signal because operationIds were derived from it.) - */ -export function isCollection(capability: Capability): boolean { - if (capability.paginated) return true; - const segments = capability.path.split("/").filter((s) => s && !s.startsWith("{")); - const tail = segments[segments.length - 1] || ""; - return tail.endsWith("s") && !tail.endsWith("ss"); -} - -/** Path words stand in for the capability name as the identity haystack. */ -function identityText(capability: Capability): string { - return capability.path - .split("/") - .filter((segment) => segment && !segment.startsWith("{") && segment !== "api") - .join(" ") - .replace(/[-_]/g, " "); -} - -function score( - capability: Capability, - wanted: string[], - aliases: Record, - hint: "" | Mode, - plural: boolean, -): { matched: number; ranked: number } { - if (wanted.length === 0) return { matched: 1, ranked: 1 }; - - const haystacks: [string, number][] = [ - [identityText(capability), 6], - [capability.entity.replace(/_/g, " "), 4], - [(aliases[capability.entity] || []).join(" "), 4], - [capability.intent || "", 2], - // `returns` is scored BELOW identity, not gated on it. At parity with intent it put a - // projects listing at #2 for "list test cases in a project" (its returns carries - // `test_cases_count`); gating it on an identity match instead made a field reachable - // only through returns unreachable, which is worse. - [(capability.returns || []).join(" ").replace(/_/g, " "), 1], - [(capability.guidance || []).join(" "), 1], - ]; - - let ranked = 0; - for (const [text, weight] of haystacks) { - const blob = new Set(terms(text)); - ranked += weight * wanted.filter((term) => blob.has(term)).length; - } - const matched = ranked; - - if (hint && capability.mode !== hint) ranked -= 20; - else if (hint && capability.mode === hint) ranked += 6; - if (plural) ranked += isCollection(capability) ? 8 : -8; - - return { matched, ranked }; -} - -export interface SearchResult { - capabilities: Capability[]; - truncated: boolean; - total_matched: number; -} - -export function searchCapabilities( - products: Record, - query?: string, - options: { entity?: string; product?: string; mode?: Mode; limit?: number } = {}, -): SearchResult { - const limit = options.limit && options.limit > 0 ? options.limit : 8; - const wanted = terms(query); - const hint = options.mode ? "" : modeHint(query); - const plural = wantsCollection(query); - - const scored: { matched: number; ranked: number; capability: Capability }[] = []; - for (const [name, bundle] of Object.entries(products)) { - if (options.product && name !== options.product) continue; - const aliases: Record = {}; - for (const [entity, doc] of Object.entries(bundle.entities)) { - aliases[entity] = ((doc as EntityDoc).aliases || []) as string[]; - } - for (const capability of bundle.capabilities) { - if (options.entity && capability.entity !== options.entity) continue; - if (options.mode && capability.mode !== options.mode) continue; - const { matched, ranked } = score(capability, wanted, aliases, hint, plural); - if (matched > 0) scored.push({ matched, ranked, capability }); - } - } - scored.sort((a, b) => b.ranked - a.ranked || - a.capability.path.localeCompare(b.capability.path)); - return { - capabilities: scored.slice(0, limit).map((row) => row.capability), - truncated: scored.length > limit, - total_matched: scored.length, - }; -} diff --git a/src/tools/capability-registry/types.ts b/src/tools/capability-registry/types.ts deleted file mode 100644 index 92e8a2e..0000000 --- a/src/tools/capability-registry/types.ts +++ /dev/null @@ -1,98 +0,0 @@ -/** - * The shape of the index artifact, which is the contract with the Python build. - * - * The artifact is generated by `capability-registry/scripts/build_index.py` and contains - * ONLY data that has already passed that side's outbound boundary. Nothing here is parsed - * from an OpenAPI spec at runtime: if this half parsed specs it would become the boundary, - * and every gate (route lint, vocabulary rule, intent lint, discovery denylist) would have - * to be reimplemented and re-tested here. Reading a pre-projected index means the internal - * data is not in the package at all. - */ - -/** Bumped by the generator when the artifact's shape changes. */ -export const SUPPORTED_SCHEMA_VERSION = 1; - -export type Mode = "read" | "write" | "destructive"; - -/** One parameter, under the name the OpenAPI spec itself gives it. */ -export interface WireParam { - name: string; - type: string; - required?: true; - values?: unknown[]; - example?: unknown; - description?: string; - /** Field names/types one level inside an array item or nested object. */ - fields?: { name: string; type: string; required?: true }[]; - /** - * Where a body field sits in the JSON, when that differs from its name. Published - * because the nesting is not guessable and getting it wrong fails silently — tm's folder - * create really wants `{folder: {name}}` while the spec's flat `{name}` is what a reader - * would assume. - */ - json_path?: string; -} - -/** A capability, keyed by the endpoint it exposes. There is deliberately no name. */ -export interface Capability { - method: string; - path: string; - mode: Mode; - entity: string; - path_params?: WireParam[]; - query?: WireParam[]; - body?: WireParam[]; - intent?: string; - guidance?: string[]; - /** Allowlisted row fields. Absent when `shape` is "discovered". */ - returns?: string[]; - /** "discovered" when the product declares no response schema for this operation. */ - shape?: "discovered"; - requires?: string[]; - paginated?: true; - max_items?: number; -} - -export interface EntityDoc { - title?: string; - aliases?: string[]; - id_convention?: string; - parents?: string[]; - relations?: { entity?: string; via?: string }[]; - [key: string]: unknown; -} - -/** - * Paging controls, keyed "METHOD /path". - * - * NOT USED BY THIS SERVER any more: it performs one request and returns the response, so - * paging belongs to the caller and the page parameters are published with the endpoint's - * other query parameters. The field is still emitted by the build, so it stays described - * here rather than silently ignored — a consumer that DOES page can use it. - */ -export interface PagingRule { - page?: string; - size?: string; - /** The largest page the operation declares. Absent when the spec states no maximum. */ - max?: number; -} - -export interface ProductIndex { - summary: string; - /** - * The host the HARNESS declares for this product, if any. Config overrides it — the same - * precedence Atlas uses. Absent for tm, whose product.yaml deliberately leaves the host - * to config so that per-account region sharding can be honoured. - */ - base_url?: string; - capabilities: Capability[]; - entities: Record; - paging?: Record; -} - -export interface RegistryIndex { - schema_version: number; - build_id: string; - harness_commit?: string; - products: Record; -} diff --git a/tests/fixtures/registry-index.json b/tests/fixtures/registry-index.json deleted file mode 100644 index 1c30ff6..0000000 --- a/tests/fixtures/registry-index.json +++ /dev/null @@ -1 +0,0 @@ -{"schema_version":1,"build_id":"8084a81bd6-173caps-b381220","harness_commit":"b3812207","products":{"tm":{"summary":"BrowserStack Test Management API (v1 \u2014 the SSO/OAuth app API, cookie-authenticated).","capabilities":[{"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"}],"body":[{"name":"owner","type":"integer","description":"The ID of the user to assign as the owner of the test run."}],"intent":"Assign a owner to the test run","guidance":["Assign a user as the owner of a specific test run within a project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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, verified live: 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":["Archive multiple test cases within a project","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"paginated":true},{"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,"description":"Plan INTEGER ids."}],"intent":"Archive test plans in bulk (reversible \u2014 prefer this over delete)","guidance":["REVERSIBLY remove plans from view","Async above the bulk threshold","Soft-archive one or more plans","reach for it whenever the user wants plans \"removed\" or \"cleaned up\" without saying they must be destroyed"],"returns":["async","unique_id"]},{"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"}],"query":[{"name":"include_subfolders_tc","type":"boolean"}],"body":[{"name":"assignee_id","type":"integer","example":2,"description":"ID of the assignee to whom the test cases will be assigned"},{"name":"mapping_ids","type":"array","example":[999,991,1024,1019],"description":"List of test case IDs to be linked"},{"name":"select_all","type":"boolean","example":false,"description":"Flag to select all test cases"},{"name":"de_selected_ids","type":"array","description":"List of test case IDs to be de-selected"},{"name":"folder_id","type":"integer","description":"ID of the folder to which the test cases belong"}],"intent":"Bulk assign test cases to a test run","guidance":["assign specific cases within a run to a user (async above 300)","Assign multiple test cases to a specific test run within a project, allowing for bulk operations on test case assignments"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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` \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, verified live: 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":"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,"json_path":"/test_case/ids"},{"name":"de_selected_ids","type":"array","required":true,"json_path":"/test_case/de_selected_ids"},{"name":"select_all","type":"boolean","required":true,"json_path":"/test_case/select_all"},{"name":"folder_id","type":"string","description":"SOURCE folder that scopes the selection, at top level. OPTIONAL when you pass explicit `ids` (verified live: the copy succeeds without it), but send it whenever `select_all` is true \u2014 it is what bounds the set, and `select_all` with `folder_id` and no `q` copies exactly that folder's cases. Integer and string are both accepted; the declared string matches what the UI sends here, which unlike bulk-"},{"name":"destination_folder_id","type":"integer","required":true,"description":"Target folder for the copies, and the one genuinely required destination field \u2014 omitting it is a bare `400`, verified live. NOTE it is TOP-LEVEL for copy, unlike bulk-move where the destination sits inside `test_case`."},{"name":"destination_project_id","type":"string","description":"Target project id. Needed only for a copy to ANOTHER project. For a copy WITHIN one project it is OPTIONAL \u2014 verified live, omitting it copies correctly into `destination_folder_id`, and integer and string are both accepted. The product does both: its Copy/Move modal sends the source project id, its clone / drag-and-drop path omits it. Send the source id to be explicit or omit it; never invent a v"}],"intent":"Bulk copy test cases","guidance":["copy a selection to a folder (async above 30)","Bulk copy test cases from one folder to another within a project","This operation allows you to copy multiple test cases at once, which can be useful for organizing or duplicating test cases across different folders","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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` \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, verified live: 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 delete.","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":"Delete every case in scope, minus `de_selected_ids`.","json_path":"/test_case/select_all"}],"intent":"Bulk Delete Test Cases from Search","guidance":["All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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."}],"intent":"Delete test results in bulk \u2014 PARTIAL SUCCESS is normal, always read `skipped`","guidance":["Authorisation is enforced per id (a caller must be the result's author or a project admin)","treating a 200 as \"all deleted\" will silently misreport","result_ids must be a non-empty array (400 otherwise) of at most 300 ids (422 beyond that)","batch larger sets yourself"],"returns":["id","reason"]},{"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` \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, verified live: 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 edit.","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":"automation_status","type":"string","json_path":"/test_case/automation_status"},{"name":"case_type","type":"integer","json_path":"/test_case/case_type"},{"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"},{"name":"owner","type":"integer","description":"TM user id.","json_path":"/test_case/owner"},{"name":"preconditions","type":"string","json_path":"/test_case/preconditions"},{"name":"priority","type":"integer","json_path":"/test_case/priority"},{"name":"status","type":"integer","json_path":"/test_case/status"},{"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"}],"intent":"Bulk Update Test Cases","guidance":["bare values, REPLACE-only (async above 100)","Update multiple test cases within a project","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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"}],"query":[{"name":"include_subfolders_tc","type":"boolean"},{"name":"status_id","type":"integer"}],"body":[{"name":"attachments","type":"array","example":[]},{"name":"de_selected_ids","type":"array","example":[]},{"name":"description","type":"string","example":"

something

","description":"HTML formatted comment or note"},{"name":"folder_id","type":"integer","example":21},{"name":"issues","type":"array","example":[]},{"name":"mapping_ids","type":"array","example":[999,991,1024,1019]},{"name":"select_all","type":"boolean","example":false},{"name":"status_id","type":"integer","example":21},{"name":"test_run_ids","type":"array","example":[1001,1002,1003],"description":"Array of BTCER IDs to apply the bulk edit to (passed when automation = true)"},{"name":"test_case_counts","type":"array","example":[1,3,2],"description":"Array of test case counts corresponding to each BTCER ID in test_run_ids (passed when automation = true)"}],"intent":"Bulk edit test cases result in test run","guidance":["Update multiple test cases in a specific test run within a project, allowing for bulk operations such as changing status, adding attachments, and more"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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, verified live: 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},{"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},{"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`.\n\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},{"name":"value","type":"string"}],"json_path":"/test_case/priority"},{"name":"status","type":"string","fields":[{"name":"operation","type":"string","required":true},{"name":"value","type":"string"}],"json_path":"/test_case/status"},{"name":"case_type","type":"string","fields":[{"name":"operation","type":"string","required":true},{"name":"value","type":"string"}],"json_path":"/test_case/case_type"},{"name":"automation_state","type":"string","fields":[{"name":"operation","type":"string","required":true},{"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},{"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},{"name":"value","type":"string"}],"json_path":"/test_case/owner"},{"name":"preconditions","type":"string","description":"Text value.","fields":[{"name":"operation","type":"string","required":true},{"name":"value","type":"string"}],"json_path":"/test_case/preconditions"},{"name":"review_status","type":"string","fields":[{"name":"operation","type":"string","required":true},{"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},{"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":["per-field { value, operation }","Correct for one case too (pass a single id)","Update many test cases in ONE call","Every field is an object of the form {\"value\": \u2026, \"operation\": \u2026}","so this is the only write path that can ADD or REMOVE from a collection without replacing it","one call for N cases instead of N calls"],"returns":["async","unique_id"],"paginated":true},{"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` \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, verified live: 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 move.","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":"Move every case in scope, minus `de_selected_ids`.","json_path":"/test_case/select_all"},{"name":"destination_folder_id","type":"integer","description":"Target folder id. Falls back to `folder_id` when omitted.","json_path":"/test_case/destination_folder_id"},{"name":"folder_id","type":"integer","description":"Legacy alias for `destination_folder_id`, used only when that is absent.","json_path":"/test_case/folder_id"},{"name":"destination_project_id","type":"integer","description":"Set only for a cross-project move. Shared cases cannot be moved across projects (422).","json_path":"/test_case/destination_project_id"}],"intent":"Bulk Move Test Cases","guidance":["Move multiple test cases from one folder to another within a project","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"paginated":true},{"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":"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, verified live: 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 Retrieve Test Cases","guidance":["Retrieve multiple test cases within a project based on specified criteria","All three selection keys are required: with select_all: true send ids: [] and the server resolves the set from q + folder","with select_all: false it uses ids minus de_selected_ids","The bulk-actions flow covers when to use which, and how to validate a q before writing","an unrecognised q key is silently dropped, which WIDENS the target set","A selection resolving to ZERO cases is refused with 400 {\"success\": false} and no message"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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,"description":"Plan INTEGER ids."}],"intent":"Un-archive test plans in bulk (undo bulk-archive)","guidance":["Restore previously archived plans"],"returns":["async","unique_id"]},{"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":"Clone an exploratory session (async when it has many logs).","guidance":["defects are the issues linked to the session","the parent project takes the INTEGER project id (v1)","List defaults to active sessions"]},{"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","description":"Name for the clone. Defaults to a server-generated copy name.","json_path":"/test_plan/name"},{"name":"copy_all_sub_plans","type":"boolean","json_path":"/test_plan/copy_all_sub_plans"},{"name":"sub_plan_ids","type":"array","description":"Clone only these sub-plans. Ignored when copy_all_sub_plans is true.","json_path":"/test_plan/sub_plan_ids"}],"intent":"Clone a test plan, optionally with its sub-plans","guidance":["copy_all_sub_plans or sub_plan_ids to bring its sub-plans along","Copy an existing plan","copy_all_sub_plans: true clones every sub-plan","alternatively pass sub_plan_ids to clone a specific subset","Omit both to clone just the plan itself"]},{"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"}],"body":[{"name":"copy_tc_assignee","type":"boolean","description":"Copy test case assignees from the original run"},{"name":"copy_issues","type":"boolean","description":"Copy issues linked to the original run"},{"name":"copy_tags","type":"boolean","description":"Copy tags from the original run"},{"name":"copy_all_cases","type":"boolean","description":"Include all test cases in the cloned run"},{"name":"status","type":"array","example":["passed","failed"],"description":"Status strings to filter test cases to copy"},{"name":"status_id","type":"array","example":[1,2],"description":"Status IDs to filter test cases to copy"},{"name":"name","type":"string","example":"Cloned Test Run - 2025","description":"Name for the new cloned test run"}],"intent":"Clone a test run","guidance":["Create a new test run by cloning an existing one, with options to copy test case assignees, issues, tags, and filter cases by status"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"Close an exploratory session","guidance":["Transitions an active exploratory session to the closed state"]},{"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"}],"intent":"Close a test run","guidance":["Close a specific test run within a project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"ID of folder to copy"},{"name":"destination_folder_id","type":"integer","required":true,"example":3,"description":"ID of destination parent folder"},{"name":"destination_project_id","type":"integer","required":true,"example":10000,"description":"Destination project ID (can be same or different)"},{"name":"async","type":"boolean","example":true,"description":"Whether to process asynchronously via background job"},{"name":"copy_test_cases","type":"boolean","example":true,"description":"Whether to copy test cases within folder"}],"intent":"Copy a folder to another location","guidance":["Copies a folder (and optionally its contents) to a destination folder within the same or different project","Supports both synchronous and asynchronous operations via the async flag","Async Processing: When async: true, the operation is queued as a Sidekiq background job and returns immediately with a unique tracking ID"],"returns":["async","folder_id","unique_id"]},{"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","example":false,"description":"Select every case in the project.","json_path":"/selection/select_all"},{"name":"unique_test_case_count","type":"integer","example":2,"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.","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`."},{"name":"configurations","type":"array","required":true,"example":[],"description":"Configuration ids. Required \u2014 pass an empty array if there are none."},{"name":"trtc_configuration_mappings","type":"array","description":"Per-configuration case mappings. An ARRAY of objects \u2014 the server rejects an object here.","fields":[{"name":"configuration_id","type":"integer"},{"name":"select_all","type":"boolean"},{"name":"linked_test_cases","type":"array"},{"name":"unlinked_test_cases","type":"array"}]}],"intent":"Count the test cases a selection resolves to (incl. configurations/datasets).","guidance":["The discovered ids must be CARRIED INTO the create body","discovery alone puts nothing in the run","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","It is validated before the selection","so a bare 400 'invalid data' means a bad test_run field"],"shape":"discovered"},{"method":"GET","path":"/api/v1/projects/{project_id}/test-plans/{test_plan_id}/test-runs/count","mode":"read","entity":"test_plan","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"test_plan_id","type":"integer","required":true}],"intent":"Count the test runs linked to a test plan","guidance":["how many runs a plan groups","cheaper and safer than paging the run list","Returns just the count of runs linked to the plan","Prefer this over paging the run list when the user only asked \"how many\"","list reads truncate around 30 KB and will make you under-count"],"shape":"discovered"},{"method":"POST","path":"/api/v1/projects/{project_id}/folder/{folder_id}/bulk-test-cases","mode":"write","entity":"folder","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"folder_id","type":"integer","required":true}],"body":[{"name":"test_cases","type":"array","required":true,"fields":[{"name":"name","type":"string","required":true},{"name":"test_case_folder_id","type":"string","required":true},{"name":"attachments","type":"array"},{"name":"automation_state","type":"integer"},{"name":"automation_status","type":"string"},{"name":"case_type","type":"integer"},{"name":"custom_fields","type":"object"},{"name":"description","type":"string"},{"name":"estimate","type":"string"},{"name":"estimated_duration_seconds","type":"integer"},{"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"},{"name":"reviewers","type":"array"},{"name":"send_for_review","type":"boolean"},{"name":"shared_precondition_id","type":"integer"},{"name":"status","type":"integer"},{"name":"tags","type":"array"},{"name":"template","type":"string"},{"name":"template_id","type":"integer"},{"name":"template_step_type","type":"string"},{"name":"test_case_dataset","type":"string"},{"name":"test_case_steps","type":"array"}]},{"name":"unique_id","type":"string","description":"Client-supplied idempotency key. When present and the group has the async bulk-create feature flag enabled, the request is processed asynchronously and acknowledged with 202; completion is delivered over the common WebSocket channel keyed by this id."}],"intent":"Create test cases in bulk for a folder (\u226410) \u2014 UNRELIABLE STATUS, see description.","guidance":["Creates several test cases in one request","more returns 400 \"More than permitted test cases sent\"","A 500 here does NOT mean nothing happened","The action is wrapped in a catch-all rescue that renders 500 {\"success\": false, \"message\": \"Failed to create test cases.\"} for any exception, including ones raised AFTER the cases were already inserted","Same status, same message, opposite outcomes","So on a 500: never retry blind"],"returns":["request_trace_id"]},{"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 new configuration for a project","guidance":["there is no flat, account-level list","Each configuration has an INTEGER id","the list returns a configurations[] array plus page info"],"returns":["id","name","created_at","group_id","is_suggested","record_status"]},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"body":[{"name":"option_value","type":"string","required":true,"description":"The option label."},{"name":"is_default","type":"boolean","required":true,"description":"REQUIRED \u2014 a missing value is a 400. Must be 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 parent option this hangs under."}],"intent":"Add one option to a dataset \u2014 how you add a dropdown value.","guidance":["option_value + is_default both required","Adds a single option","Both option_value and is_default are required","a missing is_default is 400 \"Invalid Params\"","For dropdown, at most one option may be the default"]},{"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}],"body":[{"name":"linked_projects","type":"array","description":"Project INTEGER ids this dataset applies to. This is what makes the field visible in a project \u2014 a dataset with no linked projects leaves the field invisible everywhere. NOTE the spelling differs from the project-mappings endpoint, which uses `link_projects` / `unlink_projects`."},{"name":"link_to_future_projects","type":"boolean","description":"Auto-link projects created later."},{"name":"linked_to_all_projects","type":"boolean","description":"Apply to every project in the workspace."},{"name":"options","type":"array","description":"The allowed values, for dropdown-style fields.","fields":[{"name":"option_value","type":"string","required":true},{"name":"is_default","type":"boolean","required":true},{"name":"parent_option_id","type":"integer"}]}],"intent":"Add a dataset (an option set + its project links) to an existing field.","guidance":["A dataset is how a field carries options and project scope","A field can hold more than one, letting different projects see different option sets for the same field","the key is linked_projects, and options is accepted at this level (it is a dataset body, not a field body)","for other types a dataset with linked_projects and no options is what scopes the field to projects"]},{"method":"POST","path":"/api/v1/admin-v2/settings/custom-fields","mode":"write","entity":"custom_field","body":[{"name":"field_name","type":"string","required":true},{"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 (e.g. `field_dropdown`) are a different API's and are rejected. Silently immutable after creation: an update that changes it returns 200 and changes nothing."},{"name":"entity_type","type":"string","values":["TestCase","TestResult","TestPlan","ExploratorySession"],"description":"Which entity the field hangs off. One field belongs to exactly one."},{"name":"is_required","type":"boolean","required":true,"description":"REQUIRED, and must be a literal boolean \u2014 omitting it is a 400."},{"name":"datasets","type":"array","required":true,"description":"REQUIRED for every field type, dropdown or not \u2014 omitting it is a 400. Carries the options and the project scope. Send one entry with `linked_projects` set.","fields":[{"name":"linked_projects","type":"array"},{"name":"link_to_future_projects","type":"boolean"},{"name":"linked_to_all_projects","type":"boolean"},{"name":"options","type":"array"}]},{"name":"place_holder_text","type":"string"},{"name":"default_value","type":"string","description":"Only honoured when `field_type` is `boolean`."},{"name":"levels","type":"array","description":"REQUIRED when field_type is `nested_dropdown`, minimum 2 entries, each {name, level_type}."}],"intent":"Create a custom field DEFINITION (workspace-admin action).","guidance":["options AND project scope both go in datasets[]","Creates the field definition","a new attribute available on the chosen entity type","configurable per workspace","so don't predict access","A caller without it gets 403"]},{"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,"description":"Name of the dataset.","json_path":"/dataset/name"},{"name":"variables","type":"array","required":true,"description":"List of dataset variables/columns (max 40).","fields":[{"name":"name","type":"string","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/variables"},{"name":"rows","type":"array","required":true,"description":"List of dataset rows (max 100).","fields":[{"name":"row_number","type":"integer","required":true},{"name":"row_data","type":"array","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/rows"}],"intent":"Create a new dataset.","guidance":["create a dataset with variables + rows","Create a new dataset with variables and rows for a project"],"returns":["name","created_at","rows_count","uuid"]},{"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.","json_path":"/exploratory_session/title"},{"name":"description","type":"string","example":"Explore edge cases in the checkout flow.","description":"Optional description.","json_path":"/exploratory_session/description"},{"name":"charter","type":"string","example":"Focus on payment error handling","description":"Testing charter describing the scope and goals.","json_path":"/exploratory_session/charter"},{"name":"timebox_duration","type":"integer","example":60,"description":"Planned duration in minutes.","json_path":"/exploratory_session/timebox_duration"},{"name":"owner","type":"integer","example":42,"description":"TCM user ID of the session owner / assignee.","json_path":"/exploratory_session/owner"},{"name":"assignee","type":"integer","example":42,"description":"Alias for `owner`. TCM user ID of the assignee.","json_path":"/exploratory_session/assignee"},{"name":"folder_id","type":"integer","example":456,"description":"ID of the folder to place this session in.","json_path":"/exploratory_session/folder_id"},{"name":"tags","type":"array","example":["regression","mobile"],"description":"Tags for the session.","json_path":"/exploratory_session/tags"},{"name":"configurations","type":"array","example":[1,3],"description":"Configuration IDs to associate with this session.","json_path":"/exploratory_session/configurations"},{"name":"attachments","type":"array","example":[101,102],"description":"Blob / media attachment IDs.","json_path":"/exploratory_session/attachments"},{"name":"issues","type":"array","description":"Issues to link to this session.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/exploratory_session/issues"}],"intent":"Create an exploratory session","guidance":["body wrapped in exploratory_session (title required)","Creates a new exploratory session within the specified project"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"]},{"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 button \u2014 spinner appeared but order was not created.

","description":"Rich-text HTML content of the log entry.","json_path":"/session_log/content"},{"name":"status","type":"string","values":["pass","fail","bug","retest","block","note"],"example":"fail","description":"Test result status for this log step.","json_path":"/session_log/status"},{"name":"elapsed_time","type":"integer","example":120,"description":"Time elapsed for this step in seconds.","json_path":"/session_log/elapsed_time"},{"name":"defects","type":"array","description":"Defects to link to this log entry.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/session_log/defects"}],"intent":"Create a session log entry","guidance":["add a log entry (rich-text HTML, sanitised on write)","Adds a new log entry to the specified exploratory session","Rich-text HTML content is sanitised before storage"],"returns":["id","status","content","created_at","elapsed_time","session_id","updated_at"]},{"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,"description":"Name of the filter"},{"name":"entity","type":"string","required":true,"values":["testcase","testplan","testrun","exploratory_session","test_plan_test_case"],"description":"Entity type the filter applies to. The server's validator accepts five values (the\nlast two were missing here); an unlisted value returns 400 \"Invalid JSON data\"."},{"name":"is_project_level","type":"boolean","description":"Whether this filter is available at project level"},{"name":"filters","type":"object","required":true,"description":"Filter criteria object containing the actual filter conditions"}],"intent":"Create a new filter","guidance":["save a filter view ({ name, entity, filters, is_project_level })","Create a new saved filter for test cases, test plans, or test runs in a project"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"]},{"method":"POST","path":"/api/v1/projects","mode":"write","entity":"project","body":[{"name":"name","type":"string","required":true,"json_path":"/project/name"},{"name":"description","type":"string","json_path":"/project/description"}],"intent":"Create a new project.","guidance":["Pinned to human approval","Create a new project with name and description"],"returns":["name","description"]},{"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},{"name":"report_type","type":"string","required":true,"values":["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","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_statu"},{"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 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`"},{"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;\n`custom_report` is a user-configured one. For a Requirement Traceability\nreport, `system_generated` makes the server auto-populate\n`report_filters.requirements` with every requirement in the project."},{"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"},{"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":"Integer run ids \u2014 pairs with report_timeframe=specific_test_runs.","json_path":"/report_filters/test_runs"},{"name":"test_plans","type":"array","description":"Integer plan ids \u2014 pairs with report_timeframe=specific_test_plans.","json_path":"/report_filters/test_plans"},{"name":"test_cases","type":"string","description":"Case selection \u2014 pairs with report_timeframe=specific_test_cases. FOLDER-KEYED\n(see SelectionData), never a flat id list: the server resolves the selection\nthrough its folder map, so any other shape yields zero cases and saves an\nUNSCOPED, project-wide report at 200.\n\nSend all three keys. Folder ids are STRING keys; test-case ids are INTEGERS\n(the numeric `id`, not the TC-NNN identifier) \u2014 take bo","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":"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.","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 } }.","json_path":"/report_filters/test_plan_selection"},{"name":"builds","type":"array","json_path":"/report_filters/builds"},{"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\n`Test Run Summary`, `Test Run Detailed Report` and `Test Case Activity` \u2014 on\nthe other six report types the server never reads it and drops it silently.\nPersisted (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`."},{"name":"users","type":"array","json_path":"/mail_to/users"},{"name":"external_mails","type":"array","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\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"},{"name":"time","type":"string","example":"08:00","description":"24-hour HH:MM, UTC.","json_path":"/frequency_details/time"},{"name":"day","type":"string","example":"monday","description":"Required for weekly (lowercase day name) and monthly (integer 1-28).\nOmit for daily.","json_path":"/frequency_details/day"}],"intent":"Create a new scheduled report for a project","guidance":["Create a report configuration under the given project"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"]},{"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","description":"Name of the root folder"},{"name":"notes","type":"string","example":"This folder contains all regression test cases","description":"Optional notes or description for the folder"}],"intent":"Create a root-level folder for test cases in a project","returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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},{"name":"folder_id","type":"integer","description":"ID of the shared-field folder to place the shared step in. Null or omitted means the root (Unassigned)."},{"name":"shared_step_details","type":"array","required":true,"fields":[{"name":"order","type":"integer","required":true},{"name":"step","type":"string"},{"name":"result","type":"string"},{"name":"test_data","type":"string"}]}],"intent":"Create a new shared step in a project","guidance":["read the shared step first and send back the full array with your edit applied, or you will drop steps","Include each detail's id to update it in place rather than recreating it","A shared step is embedded by MANY test cases","so editing or deleting it changes every one of them at once","say how it is used before changing it"],"returns":["id","title","step_count","test_case_count"]},{"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},{"name":"test_run_id","type":"string","required":true},{"name":"test_case_id","type":"integer","required":true},{"name":"step_id","type":"integer","required":true}],"body":[{"name":"status_id","type":"integer","json_path":"/test_run_step_result/status_id"},{"name":"description","type":"string","json_path":"/test_run_step_result/description"}],"intent":"Record a per-step result for a case in a run (v1).","guidance":["record a per-step outcome for a step-templated case ({ status_id, description })"]},{"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,"description":"Name of the new folder.","json_path":"/folder/name"},{"name":"notes","type":"string","description":"Description of the new folder.","json_path":"/folder/notes"}],"intent":"create subfolder inside a specific folder","guidance":["Create a new subfolder within a specific folder in a project"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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 the FIRST test case in a project that has no folders (needs create_at_root).","guidance":["Narrow-purpose route: the first case in a project that has zero folders","The route and the flag always travel together","The product's UI derives both from one boolean","no folders exist AND the user picked no folder","A correct refusal, not something to retry: list the folders and pick one","The text sits under errors[], not message"],"returns":["result","step"]},{"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,"fields":[{"name":"name","type":"string","required":true},{"name":"test_case_folder_id","type":"string","required":true},{"name":"attachments","type":"array"},{"name":"automation_state","type":"integer"},{"name":"automation_status","type":"string"},{"name":"case_type","type":"integer"},{"name":"custom_fields","type":"object"},{"name":"description","type":"string"},{"name":"estimate","type":"string"},{"name":"estimated_duration_seconds","type":"integer"},{"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"},{"name":"reviewers","type":"array"},{"name":"send_for_review","type":"boolean"},{"name":"shared_precondition_id","type":"integer"},{"name":"status","type":"integer"},{"name":"tags","type":"array"},{"name":"template","type":"string"},{"name":"template_id","type":"integer"},{"name":"template_step_type","type":"string"},{"name":"test_case_dataset","type":"string"},{"name":"test_case_steps","type":"array"}]},{"name":"create_at_root","type":"boolean"}],"intent":"Create test cases for a folder in a project.","guidance":["body wrapped in test_case, name required","Create one or more test cases within a specific folder in a project","If create_at_root is true, the test case will be created at the root of the project"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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","json_path":"/test_plan/name"},{"name":"description","type":"string","json_path":"/test_plan/description"},{"name":"start_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/start_date"},{"name":"end_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/end_date"},{"name":"plan_status","type":"string","description":"Plan status. The list filter accepts new / started / completed.","json_path":"/test_plan/plan_status"},{"name":"owner","type":"integer","description":"Owner user id \u2014 resolve via the project users read first.","json_path":"/test_plan/owner"},{"name":"parent_plan_id","type":"integer","description":"Parent plan's INTEGER id. Set it to create (or re-parent into) a SUB-plan \u2014 the\nresult carries an STP-NNN identifier. Null/omitted means a top-level plan.","json_path":"/test_plan/parent_plan_id"},{"name":"tags","type":"array","json_path":"/test_plan/tags"},{"name":"reviewers","type":"array","description":"Reviewer user ids.","json_path":"/test_plan/reviewers"},{"name":"attachments","type":"array","description":"Attachment ids already uploaded via the attachments surface.","json_path":"/test_plan/attachments"},{"name":"custom_fields","type":"object","json_path":"/test_plan/custom_fields"},{"name":"issues","type":"array","description":"Linked tracker issues. Structured \u2014 NOT a flat array of keys.","fields":[{"name":"issue_id","type":"string"},{"name":"issue_type","type":"string"},{"name":"metadata","type":"object"}],"json_path":"/test_plan/issues"},{"name":"test_runs","type":"string","description":"The plan's linked test runs. Accepts EITHER an array of test-run integer ids, OR a\nbulk-selection object for \"every run matching this filter\". On update this REPLACES\nthe existing link set rather than appending.","json_path":"/test_plan/test_runs"}],"intent":"Create a test plan \u2014 or a SUB-plan, by setting parent_plan_id","guidance":["body wrapped in test_plan","Create a test plan in the project","Everything goes under the test_plan key","test_runs accepts two shapes","Both are honoured server-side","an unknown option is rejected"]},{"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"},{"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":["The case is in the PATH here, not the body","Bodies are wrapped in test_result","set status by NAME (status) or id (status_id)","use a real configured status (see statuses-and-states)","so a stray string silently CREATES a link","Deleting removes an execution record and the case's latest status is recomputed from what remains, which can silently change the run's reported state"],"returns":["id","status","backtrace","configuration_id","created_at","created_by_imported","custom_fields","description","error_description","expanded","failure_type","finished_at"]},{"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],"description":"Explicit case ids to pin into the run. Top level, NOT inside `test_run`. Use this or `selection`."},{"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":["Create a new test run for a specific project, which can be used to execute test cases"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"dataset_id","type":"integer","required":true},{"name":"option_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"Delete one option (POST to /delete) \u2014 DESTRUCTIVE, drops values using it.","guidance":["destroys the values that used it","Removing an option deletes the stored values that used it, across every project linked to the dataset","that preserves the values"]},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"Delete a dataset (POST to /delete) \u2014 DESTRUCTIVE, drops its options and values.","guidance":["drops its options and the values the linked projects stored","Removes the option set and unlinks its projects, destroying the values those projects had stored for this field","Name the affected projects before calling"]},{"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}],"intent":"Delete a field definition (POST to /delete) \u2014 DESTRUCTIVE, cascades to stored values.","guidance":["destroys every stored value in every linked project","Name the projects first","Removes the definition and every value stored for it, in every project it is linked to","There is no bin and no undo","Before calling: read the field, say how many projects it is linked to, and name them","If the user only wants it hidden rather than destroyed, that is a different ask"]},{"method":"DELETE","path":"/api/v1/projects/{project_id}/datasets/{uuid}","mode":"destructive","entity":"dataset","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"uuid","type":"string","required":true}],"intent":"Delete a dataset.","guidance":["say it isn't available rather than calling it, and don't substitute by parsing the file yourself","A 422 here means NOT-ENTITLED, not a bad payload","say so and stop, don't retry with a different body","BINDING shape is DIFFERENT from the dataset shape, and this is the usual failure","the dataset's uuid repeated on every column it owns","The binding object has NO top-level uuid or name (both stripped by strong params)"]},{"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":"Delete an exploratory session","guidance":["defects are the issues linked to the session","the parent project takes the INTEGER project id (v1)","List defaults to active sessions"]},{"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":"Delete a session log entry","guidance":["Permanently deletes a log entry from the specified exploratory session"]},{"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 filter","guidance":["entity = testcase | testplan | testrun | exploratory_session","filter_id is an integer","reuse a saved view's filters as the q for a later search or bulk action"]},{"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":"Delete a folder and its contents","guidance":["Deletes a folder by its ID and optionally returns the parent folder's path hierarchy"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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"}],"intent":"Delete a report (this removes the report itself, not just its schedule)","guidance":["returns the remaining reports","merely stopping a report from recurring","The response is the list of remaining reports","There is no bin and no undo","so confirm the report's name with the user first"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"]},{"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","guidance":["affects every test case embedding it","Deletes a shared step from a project"]},{"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":"Delete an attachment from a test case in a folder in a project","guidance":["read the file from that url"],"returns":["id","byte_size","content_type","filename"]},{"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":"Delete a test plan (or sub-plan) \u2014 no bin, no undo","guidance":["The server performs no plan-type check","so this deletes a sub-plan by its own integer id exactly the same way","Deleting a parent plan is destructive to its children"]},{"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 test result","guidance":["Prefer PATCHing the latest result to correct a mistake","Deleting a result removes an execution record","the case's latest status is recomputed from what remains"]},{"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"}],"intent":"delete test run","guidance":["The discovered ids must be CARRIED INTO the create body","discovery alone puts nothing in the run","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","It is validated before the selection","so a bare 400 'invalid data' means a bad test_run field"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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},{"name":"duplicate_id","type":"integer","required":true}],"body":[{"name":"discard_reason","type":"string","description":"Short reason code / label for why this isn't a duplicate."},{"name":"user_feedback","type":"string","description":"Free-text feedback from the user."}],"intent":"Discard a duplicate suggestion \u2014 dismisses it WITHOUT touching test cases","guidance":["The safe default when the user says it's not a duplicate or is unsure","Mark a recommendation as discarded","This is the safe way to dismiss a suggestion: it changes only the recommendation's status to discarded and leaves both test cases exactly as they are","discard_reason and user_feedback are optional but feed the dedupe model","pass along whatever reason the user gave"]},{"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":"Desired export format. Note this is a STRING here, while the same-named field\non `send_email_now` is an ARRAY."},{"name":"required","type":"object","description":"Optional column selection, honoured only for `Test Run Detailed Report`\n(ignored for every other type). Shape:\n`{ \"\": { \"fields\": [\"id\",\"title\",...] } }`. Omit to get the report's\ndefault columns."}],"intent":"Initiate a download of a report in a specified file format","guidance":["Request generation and download channel for the specified report"],"returns":["channel"]},{"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"},{"name":"custom_fields","type":"object","example":{"123":"option_value","456":["multi","select"]},"json_path":"/test_case/custom_fields"},{"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"},{"name":"preconditions","type":"string","json_path":"/test_case/preconditions"},{"name":"priority","type":"integer","example":483,"json_path":"/test_case/priority"},{"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\".","json_path":"/test_case/reviewers"},{"name":"status","type":"integer","example":496,"json_path":"/test_case/status"},{"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":"string","description":"Dataset binding for a data-driven case. On this path it is applied only when `is_dataset_modified` is true.","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"}],"intent":"Partially edit a test case (v2)","guidance":["PREFERRED partial update","send ONLY changed fields","Partially update the details of a test case within a specific folder in a project","it is the authoritative list","send test_case_steps instead - estimate is accepted and then dropped","estimated_duration_seconds is the one that persists"],"returns":["result","step"]},{"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"},{"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"},{"name":"custom_fields","type":"object","json_path":"/test_case/custom_fields"},{"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"},{"name":"name","type":"string","json_path":"/test_case/name"},{"name":"owner","type":"integer","json_path":"/test_case/owner"},{"name":"preconditions","type":"string","json_path":"/test_case/preconditions"},{"name":"priority","type":"integer","json_path":"/test_case/priority"},{"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"},{"name":"tags","type":"array","json_path":"/test_case/tags"},{"name":"template","type":"string","json_path":"/test_case/template"},{"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":"string","description":"Dataset binding for a data-driven case. On create the mappings are always regenerated from this, so `is_dataset_modified` is ignored here.","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","json_path":"/test_case/test_case_steps"}],"intent":"Edit a test case","guidance":["Update the details of a test case within a specific folder in a project","Omitted fields are left untouched EXCEPT template, template_id and review_status, which are reset to defaults"],"returns":["result","step"]},{"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"}],"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],"description":"Explicit case ids to pin into the run. Top level, NOT inside `test_run`. Use this or `selection`."},{"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":"Edit Test Run","guidance":["Update the details of a specific test run within a project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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","pdf","excel"],"example":"csv","description":"Export file format"},{"name":"start_date","type":"string","example":"2025-01-01","json_path":"/filters/start_date"},{"name":"end_date","type":"string","example":"2025-12-31","json_path":"/filters/end_date"},{"name":"status","type":"array","example":["passed","failed"],"json_path":"/filters/status"}],"intent":"Export dashboard analytics data","guidance":["Initiates an asynchronous export of dashboard analytics data in the specified format","The export is processed via background job and returns an export ID for tracking","Async Processing: Data export runs via Sidekiq ExportDashboardWorker::FileExportWorker","Supported Formats: CSV, PDF, Excel (based on type parameter)"],"returns":["export_id"]},{"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":"Get active test run result statistics","guidance":["Retrieve statistics of active test runs for a specific project"],"shape":"discovered"},{"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":"Get archived test cases.","guidance":["Retrieve a list of archived test cases in a specific project"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"Count archived test plans","guidance":["Returns {success, count}","the number of archived plans in the project"],"shape":"discovered"},{"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 automation stats analytics","guidance":["Retrieve automation statistics for a specific project"],"returns":["automated_coverage","automated_test_cases","empty_data","manual_test_cases","total_test_cases"]},{"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":"Get all the closed test runs for a project","guidance":["Retrieve a list of all closed test runs for a specific project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"Get closed test run analytics","guidance":["Retrieve statistics of closed test runs for a specific project"],"returns":["month"]},{"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":"Get closed test runs split analytics","guidance":["Retrieve split statistics of closed test runs for a specific project"],"returns":["name"]},{"method":"GET","path":"/api/v1/projects/{project_id}/configurations","mode":"read","entity":"configuration","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Get configurations for a project","guidance":["Retrieve a list of configurations for a specific project"],"returns":["id","name","created_at","group_id","is_suggested","record_status"]},{"method":"GET","path":"/api/v1/admin-v2/settings/custom-fields/{custom_fields_id}/datasets/{dataset_id}","mode":"read","entity":"custom_field","path_params":[{"name":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"Read one dataset \u2014 its project links and option set.","guidance":["If nothing matches, the field doesn't exist in that workspace","say so rather than guessing a field id","Multi-dropdowns need OPTION IDS, not labels","That legacy family writes a table local to the Rails app that is DEPRECATED and that nothing reads","It is blocked at the gate","testhub owns definitions"],"shape":"discovered"},{"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}],"intent":"Read one custom field definition, with its datasets and project links.","guidance":["do this BEFORE any update, which is a full replace","Options are NOT inline","Datasets come back WITHOUT their options inline","Read this before any update","so you need the current values to avoid clearing them"],"shape":"discovered"},{"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},{"name":"entity_type","type":"string","required":true,"values":["test-case","test-result","test-plan"]},{"name":"field_id","type":"string","required":true}],"query":[{"name":"q","type":"string"},{"name":"p","type":"integer"}],"intent":"Get the allowed values/options of one custom field.","guidance":["If nothing matches, the field doesn't exist in that workspace","say so rather than guessing a field id","Multi-dropdowns need OPTION IDS, not labels","That legacy family writes a table local to the Rails app that is DEPRECATED and that nothing reads","It is blocked at the gate","testhub owns definitions"],"shape":"discovered","paginated":true},{"method":"GET","path":"/api/v1/projects/{project_id}/datasets/{uuid}","mode":"read","entity":"dataset","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"uuid","type":"string","required":true}],"intent":"Get a specific dataset.","guidance":["read one dataset by UUID","Retrieve a specific dataset by its UUID including all variables and rows"],"returns":["name","created_at","rows_count","uuid"]},{"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},{"name":"uuid","type":"string","required":true}],"query":[{"name":"p","type":"integer"}],"intent":"Get test cases linked to a dataset","guidance":["list the test cases bound to this dataset (paged p)","Returns the test cases linked to a dataset, identified by its UUID"],"shape":"discovered","paginated":true},{"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"},{"name":"p","type":"integer"}],"intent":"Get dataset variables/columns.","guidance":["BINDING shape is DIFFERENT from the dataset shape, and this is the usual failure","the dataset's uuid repeated on every column it owns","The binding object has NO top-level uuid or name (both stripped by strong params)","so do NOT copy the dataset's read shape into it","Never report a dataset as linked from the 200 alone","A dataset is addressed by a UUID (the dataset path param), NOT an integer"],"returns":["name","uuid"],"paginated":true},{"method":"GET","path":"/api/v1/projects/{project_id}/ai-duplicates/status","mode":"read","entity":"duplicate","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Dedupe job status \u2014 use this to tell \"feature off\" from \"no duplicates\"","guidance":["ALWAYS call this when the list is empty","Status of the project's most recent dedupe run"],"returns":["status"]},{"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},{"name":"duplicate_id","type":"integer","required":true}],"intent":"Read the pre-merge snapshot of a MERGED duplicate's source test case","guidance":["after a merge, read the pre-merge snapshot of the case that was folded away","After a merge, the source case no longer exists independently","this returns the snapshot captured at merge time","the only way to show the user what was folded away","Only works for status merged (404 otherwise), and the snapshot may legitimately be absent, which also surfaces as a 404 with error: \"Source test case snapshot not available\"","Treat that as \"not retained\", not as an error to retry"],"shape":"discovered"},{"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},{"name":"duplicate_id","type":"integer","required":true}],"intent":"Read one suggested duplicate, with its test cases resolved","guidance":["do this BEFORE merging so the user can see what would be destroyed","Only status=suggested is readable","Full detail for one recommendation, including the actual test cases it links","so you can show the user what would be merged","Only status suggested is readable here","already-resolved duplicates return 404"],"shape":"discovered"},{"method":"GET","path":"/api/v1/projects/{project_id}/{entity}/filter-details","mode":"read","entity":"project","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"entity","type":"string","required":true,"values":["test-cases","trtc","test-runs"],"example":"test-cases"}],"query":[{"name":"q[query]","type":"string","example":"login functionality"},{"name":"q[folder_ids]","type":"string","example":"3306878,3669741,5422801,5422802"},{"name":"q[status]","type":"string","example":"9319,9321"},{"name":"q[priority]","type":"string","example":"550376,9261"},{"name":"q[automation_state]","type":"string","example":"18172471,18172473"},{"name":"q[case_type]","type":"string","example":"9379,9375"},{"name":"q[owner]","type":"string","example":"user123,user456"},{"name":"q[assignee]","type":"string","example":"user789,user101"},{"name":"q[tags]","type":"string","example":"regression,smoke"},{"name":"q[created_at]","type":"string","example":"2024-01-01..2024-12-31"},{"name":"q[updated_at]","type":"string","example":"2024-01-01..2024-12-31"}],"intent":"Get filter details for entity","guidance":["Retrieve filter details for a specific entity type (test-cases, trtc, or test-runs) within a project"],"returns":["id","colour","entity_type","field_name","field_value","internal_name"]},{"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":"Get an exploratory session","guidance":["read one session by INTEGER id","Returns a single exploratory session by its ID"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"]},{"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"]},{"name":"start_date","type":"string"},{"name":"end_date","type":"string"},{"name":"session_ids","type":"string"}],"intent":"Exploratory Session Summary \u2014 live view, no saved report needed","guidance":["exploratory-session summary WITHOUT creating a report","Computes an exploratory-session summary on demand","there is no Schedule row behind it","Use it to answer \"summarise our exploratory sessions\" without creating a report first","Returns session counts, bugs found, top testers (resolved to user objects) and the session list"],"shape":"discovered"},{"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":"Get filter details","guidance":["Retrieve details of a specific saved filter by its ID","It does NOT validate or strip invalid custom-field references","a filter saved with a non-existent custom-field id is returned unchanged","A missing or deleted filter comes back as 400 {\"success\": false, \"message\": \"Filter not found\"}, not 404"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"]},{"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":"Get summary of entities to be deleted when removing a folder","guidance":["PREVIEW what deleting a folder will remove before calling rm","Provides a summary of all entities (test cases, recordings, sub-folders) that would be deleted if the folder is removed"],"returns":["active_test_case_count","archived_test_case_count","sub_folders","test_recordings_count"]},{"method":"GET","path":"/api/v1/projects/{project_id}/repository/mapping","mode":"read","entity":"folder","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Get the project's folder tree (v1).","guidance":["the whole project folder TREE in one call (structure array)"],"shape":"discovered"},{"method":"GET","path":"/api/v1/group/users-v2","mode":"read","entity":"user","query":[{"name":"q","type":"string"}],"intent":"Get group users V2 with pagination","guidance":["Retrieve a paginated list of users in the group with search and filtering capabilities, WITHOUT project scoping","used by the Global Search filter dropdowns","Search by user IDs (comma-separated) or by a name string"],"returns":["id","browserstack_user_id","email","full_name","group_id","onboarded","reports_and_notification_enabled"]},{"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":"Get issues count analytics","guidance":["Retrieve the count of issues for a specific project"],"returns":["label"]},{"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","guidance":["so for an integer id this is the right read","Do NOT translate first just to fetch the project","It serves two jobs at once: (1) READ","returns the project's full detail (name, description, permissions, \u2026)","In other words it is NOT translation-only"],"returns":["id","identifier","name","created_at","description","jira_mapped","starred","test_cases_count","test_plans_count","test_runs_count","thProjectId","user_role"]},{"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}],"intent":"Get project-specific custom fields V2 for test cases","guidance":["START HERE for CUSTOM fields","Does NOT expose system-field option ids","Retrieve custom fields, default fields, and system fields for test cases in a specific project (V2 format)"],"returns":["id","default_value","entity_type","field_name","field_type","group_id","is_bulk_editable","is_filterable","is_required","linked_projects_count","place_holder_text","system_name"]},{"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"]}],"intent":"Get project settings","guidance":["Returns an array of enabled setting keys"],"returns":["in_review_count","test_plan_in_review_count"]},{"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"}],"intent":"Get project users V2 with pagination","guidance":["Retrieve paginated list of users in the group with search and filtering capabilities","Search by user IDs (comma-separated) or by name string"],"returns":["id","browserstack_user_id","email","full_name","group_id","onboarded","reports_and_notification_enabled"]},{"method":"GET","path":"/api/v1/projects/basic","mode":"read","entity":"project","query":[{"name":"p","type":"integer"},{"name":"count","type":"integer"},{"name":"q[query]","type":"string","example":"nishchay3"},{"name":"q[identifier]","type":"string","example":"PR-60863"}],"intent":"Get paginated projects (lean payload) \u2014 use this to list or count projects","guidance":["so you can reach a true total without truncating","Those two are the only recognised keys"],"returns":["id","name","description","import_id","normalisedName","starred","thProjectId"],"max_page_size":300,"paginated":true},{"method":"GET","path":"/api/v1/projects/minify","mode":"read","entity":"project","query":[{"name":"p","type":"integer"},{"name":"count","type":"integer"}],"intent":"Get all projects (minified dropdown) \u2014 has NO name search","guidance":["never use it to resolve a named project or to count them","Minified list of active projects, for dropdown-style selection","It accepts no q in any form","the backend call has no query option","so it can only page through everything","Do NOT use it to resolve a project the user named"],"returns":["id","name","description","import_id","normalisedName","starred","thProjectId"],"max_page_size":300,"paginated":true},{"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},{"name":"attachment_id","type":"integer","required":true}],"intent":"Get download URL for a report attachment","guidance":["Retrieve a secure URL to download a specific report attachment"],"returns":["url"]},{"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"},{"name":"report_data_only","type":"boolean"},{"name":"p","type":"integer"},{"name":"today","type":"string","example":"2025-05-13"}],"intent":"Retrieve a scheduled report's config and data \u2014 the general report read","guidance":["works for every report type","Full configuration plus computed data for a report of ANY type","request the section and look at the result","which is a real finding","Pass sections to compute the sections you need","several in ONE call, cheaper than one request per section"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"],"paginated":true},{"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"}],"query":[{"name":"widget_type","type":"string"},{"name":"segment","type":"string"},{"name":"p","type":"integer"}],"intent":"Fetch one report widget/section \u2014 works for EVERY report type.","guidance":["Section names are validated per type","*_drilldown sections return paginated ROWS, not aggregates","Returns a single section's data for any report type","This is the general section read","so when you need SEVERAL sections, prefer that form with a comma-separated sections list and take them in one call rather than one request per section","Most sections return the computed aggregate for a widget"],"shape":"discovered","paginated":true},{"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","requirement_traceability_report"]}],"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":"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`"},{"name":"sections","type":"string","example":"test_run_data,test_run_status"},{"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"]},{"name":"segment","type":"string"},{"name":"p","type":"integer"}],"intent":"Get summary statistics for a scheduled report \u2014 run and traceability types ONLY","guidance":["400s on every other type","so not for Test Case Activity or Test Plan Summary","including every other type in ReportType","This is not the general report-read","optionally with sections","reportTimeRange is required"],"shape":"discovered","paginated":true},{"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 of the folder to search for"},{"name":"include_folder_hierarchy","type":"boolean","description":"Whether to include folder hierarchy in the response"},{"name":"p","type":"integer"},{"name":"per_page","type":"integer"}],"intent":"Get all folders and subfolders present in the projects.","guidance":["list root folders (paged with p","Returns all folders and subfolders in a project if it exists in TestHub"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"],"max_page_size":100,"paginated":true},{"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":"Get already selected testcases for a tracebility report","guidance":["Defects Summary and Defects Detailed Report are accepted on create but have no data branch","AND it requires reportTimeRange: omitting it is a 500, not a 400","Section names are per-report-type","a name from another type is a 400","so one bad name rejects the whole call without saying which","See the reports concept for the per-type lists"],"returns":["select_all","unique_test_case_count"]},{"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":"paginated","type":"boolean"},{"name":"p","type":"integer"}],"intent":"Get shared steps","guidance":["read one shared step with its ordered step details","Retrieves a list of shared steps for a project"],"returns":["id","title"],"paginated":true},{"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"},{"name":"q[query]","type":"string"},{"name":"pre_fetch","type":"boolean"}],"intent":"Get all shared steps for a project","guidance":["Returns a list of all shared steps within a project"],"returns":["id","title","step_count","test_case_count"],"paginated":true},{"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"},{"name":"p","type":"integer"}],"intent":"THE source for a system field's option ids (priority/status/case_type/automation_state).","guidance":["a single attachment-URL field is enough to push the response past the ~30 KB cap","Supports q to search the option list and p to page it, which matters","a workspace can accumulate dozens of options on a single field"],"shape":"discovered","paginated":true},{"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":"Get all attachments for a test case in a folder in a project","guidance":["list a case's attachments","Retrieve all attachments associated with a specific test case within a folder","CURRENTLY RETURNING 500","a reproducible server error, not a bad request","Do NOT retry it and do not report it to the user as their mistake"],"returns":["id","byte_size","content_type","filename"]},{"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":"Get a test case by INTEGER id (v1, folder-scoped) \u2014 full detail + its TC-NNN identifier","guidance":["so for an integer id this v1 read is the ONLY way to fetch the case","Two jobs at once: (1) READ","NOT translation-only","Don't fall back to listing the folder to find the case: if you have the integer id, read it here directly"],"returns":["id","identifier","title"]},{"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":"Get test case count trend analytics","guidance":["Retrieve the trend of test case counts for a specific project"],"returns":["field"]},{"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","example":"folder_path"}],"intent":"Get Test Case Details","guidance":["full detail (steps, custom fields, issues) before editing","read this, improve, then write back"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"Get test case histories","guidance":["list all version records for a test case","Retrieve all history records for a specific test case"],"returns":["id","comment","created_at","entity_id","entity_type","group_id","project_id","source","user_id","version_id","version_name"]},{"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}],"intent":"Get test case history diff","guidance":["compare two versions","versions[] with exactly two ids (PAID plan)","Retrieve the diff of a specific history record for a test case"],"returns":["id","order","result","shared_step_id","step"]},{"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":"Get a specific test case history","guidance":["Retrieve details of a specific test case history by its ID"],"returns":["id","comment","created_at","entity_id","entity_type","group_id","project_id","source","user_id","version_id","version_name"]},{"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":"Get tags and metadata for a test case","guidance":["read a case's current tags","Retrieve the tags and metadata associated with a specific test case in a project"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"Get test case type split analytics","guidance":["Retrieve the split of test case types for a specific project"],"returns":["name","field","value","y"]},{"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"}],"query":[{"name":"required[fields]","type":"string","values":["count"]}],"intent":"Get paginated test cases for a test run","guidance":["page the cases in a run (each row is the case you log results against)","Retrieve a paginated list of test cases for a specific test run in a project"],"shape":"discovered"},{"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"},{"name":"per_page","type":"integer"},{"name":"required[fields]","type":"string","example":"owner,priority"},{"name":"required[custom_fields]","type":"string","example":"121,119"},{"name":"all_folders","type":"string","values":["true"],"example":"true"}],"intent":"List a project's test cases \u2014 REQUIRES all_folders=true to be project-wide.","guidance":["Without all_folders=true this returns only ONE folder's cases","the project's first root folder","So a project-wide question answered from a bare call silently under-counts by whatever sits in every other folder, and nothing in the response says so","all_folders is compared as the STRING 'true' (all_folders = params['all_folders'] == 'true')","A JSON boolean true, 1, TRUE or any other value all fail that check and silently fall back to the single-folder scope"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"max_page_size":100,"paginated":true},{"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":"Get a test plan by INTEGER id (v1) \u2014 full detail + its TP-NNN identifier","guidance":["READ one plan by INTEGER id","Read a test plan by its INTEGER id (project integer id in the path)","Don't translate first just to read the plan","this returns its full detail directly","Two jobs at once: (1) READ","the plan's full detail (under test_plan"],"returns":["identifier","name"]},{"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"},{"name":"test_run_id","type":"integer"},{"name":"today","type":"string"}],"intent":"Execution-trend chart data for ONE plan or ONE run (XOR)","guidance":["pass test_plan_id XOR test_run_id (both or neither is a 400)","Time-series execution trend backing the Insights tab chart","Scope it with exactly one of test_plan_id or test_run_id","Passing both returns 400 \"Provide either test_plan_id or test_run_id, not both\"","passing neither returns 400 \"test_plan_id or test_run_id is required\"","This returns chart data, not a widget object"],"shape":"discovered"},{"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 a test plan","guidance":["the other half of the Insights tab","Like execution-trend, this is chart data only","insights widgets themselves are not a CRUD resource here"],"shape":"discovered"},{"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"},{"name":"include_sub_test_plan_runs","type":"boolean"},{"name":"compact","type":"boolean"},{"name":"is_archived","type":"boolean"}],"intent":"List the test runs linked to a test plan","guidance":["Paginated list of the runs a plan groups","that field replaces the link set rather than appending to it","include_sub_test_plan_runs=true also returns runs belonging to the plan's sub-plans"],"shape":"discovered","paginated":true},{"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}],"query":[{"name":"p","type":"integer"},{"name":"per_page","type":"integer"}],"intent":"Get paginated custom fields for test results","guidance":["Retrieve paginated list of custom fields configured for test results in a project"],"returns":["id","default_value","entity_type","field_name","field_type","field_user_name","is_bulk_editable","is_filterable","is_required","optional","placeholder"],"max_page_size":100,"paginated":true},{"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"},{"name":"test_case_id","type":"integer","required":true}],"intent":"Get test results for a test case in a test run","guidance":["read a case's results within a run","Retrieve a list of test results for a specific test case within a test run in a project"],"returns":["id","status","backtrace","configuration_id","created_at","created_by_imported","custom_fields","description","error_description","expanded","failure_type","finished_at"]},{"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":["Read a test run by its INTEGER id (with the project's integer id in the path)","Don't translate first just to read the run","this returns its full detail directly","Two jobs at once: (1) READ","NOT translation-only"],"returns":["id","identifier","name","uuid"]},{"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},{"name":"test_run_id","type":"string","required":true}],"intent":"Get a run with its per-case rows (v1).","guidance":["the run with its per-case rows (did it pass?)"],"shape":"discovered"},{"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"}],"intent":"Get custom field values for test results in test runs","guidance":["Retrieve default field values and optionally custom fields for test results (TRTC - Test Run Test Case)"],"returns":["id","applies_to_all_projects","default_value","entity_type","field_name","field_type","is_required","link_to_future_projects","place_holder_text"]},{"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.","json_path":"/selection/select_all"},{"name":"unique_test_case_count","type":"integer","example":2,"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.","json_path":"/selection/folders"},{"name":"shared_selection","type":"object"}],"intent":"get selected test runs of a project","guidance":["Retrieve selected test runs for a specific project based on the provided selection criteria"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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":"ignore_run_type","type":"boolean"},{"name":"include_test_plan","type":"boolean"}],"intent":"Get all the test runs for a project","guidance":["list the project's (open) runs, paged with p","Retrieve a list of all test runs for a specific project"],"returns":["id","identifier","name","active_state","assignee_imported","created_at","description","environment","is_automation","is_dynamic","observability_url","owner"]},{"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":"Import a CSV dataset \u2014 NOT SUPPORTED in this profile","guidance":["say CSV import isn't available","CSV import is not supported here","If asked to import a dataset from CSV, say it isn't available and point to the Test Management UI"]},{"method":"GET","path":"/api/v1/activities","mode":"read","entity":"activity","query":[{"name":"project_id","type":"integer"},{"name":"cursor","type":"string"},{"name":"limit","type":"integer"},{"name":"actor_id","type":"integer"},{"name":"starred_only","type":"boolean"},{"name":"entity_type","type":"array"}],"intent":"Read the activity feed (audit trail) \u2014 CURSOR paged, 15-day window","guidance":["WHO changed WHAT recently","group-wide audit trail","Group-wide audit trail of who changed what","Read-only: activity events cannot be created, edited, or deleted","Two things differ from every other list in this profile: - Cursor pagination, not page numbers","so you cannot jump to a page or read a total"],"returns":["no_starred_projects"]},{"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"}],"intent":"List archived test plans","guidance":["where a 'missing' plan usually is, and the source of ids for bulk-retrieve","Paginated list of the project's archived plans","so if a plan the user names seems missing, look here before concluding it was deleted"],"shape":"discovered","paginated":true},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"intent":"List a dataset's options with their ids.","guidance":["the ids a filter or a test-case write needs"],"shape":"discovered"},{"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"},{"name":"q[uuid]","type":"string"},{"name":"p","type":"integer"}],"intent":"List datasets for a project.","guidance":["list datasets (paged p","Retrieve a paginated list of datasets for a project with optional filtering by name or UUID"],"returns":["name","created_at","rows_count","test_cases_count","updated_at","uuid"],"paginated":true},{"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"},{"name":"entity_type","type":"string"},{"name":"entity_id","type":"integer"},{"name":"duplicates","type":"string"}],"intent":"List AI-suggested duplicate test cases \u2014 an empty list may mean the feature is OFF","guidance":["LIST the duplicate suggestions awaiting review","List the AI dedupe recommendations awaiting review in a project","Only duplicates with status suggested are returned","once merged, archived, or discarded they leave this list","An empty result is ambiguous","identical to \"this project has no duplicates\""],"shape":"discovered","paginated":true},{"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":"page","type":"integer","example":1},{"name":"per_page","type":"integer","example":30}],"intent":"List defects for an exploratory session","guidance":["List defaults to active sessions","the parent project takes the INTEGER project id (v1)","defects are the issues linked to the session"],"returns":["issue_id","issue_type"],"max_page_size":100,"paginated":true},{"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":"page","type":"integer","example":1},{"name":"per_page","type":"integer","example":50}],"intent":"List logs for an exploratory session","guidance":["page the session's log entries","Returns a paginated list of log entries for the specified exploratory session"],"returns":["id","status","content","created_at","elapsed_time","session_id","updated_at"],"max_page_size":100,"paginated":true},{"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":"page","type":"integer","example":1},{"name":"per_page","type":"integer","example":30},{"name":"q[session_state]","type":"string","values":["active","closed"],"example":"active"},{"name":"q[assignee]","type":"integer","example":42},{"name":"q[owner]","type":"integer","example":42},{"name":"q[created_by]","type":"integer","example":10},{"name":"q[created_at_from]","type":"string","example":"2026-01-01"},{"name":"q[created_at_to]","type":"string","example":"2026-01-31"},{"name":"q[tags][]","type":"array","example":["regression","payment"]},{"name":"q[configuration_ids][]","type":"array","example":[1,2]},{"name":"q[search]","type":"string","example":"checkout"},{"name":"q[status]","type":"string","example":"pass"}],"intent":"List exploratory sessions for a project","guidance":["list sessions (defaults to active","Returns a paginated list of exploratory sessions"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"],"max_page_size":100,"paginated":true},{"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","type":"string","required":true,"values":["testcase","testplan","testrun","exploratory_session","test_plan_test_case"]},{"name":"page","type":"integer"}],"intent":"List filters","guidance":["list saved filter views (optional entity = testcase|testplan|testrun|exploratory_session)","Retrieve a paginated list of saved filters for a project, optionally filtered by entity type"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"],"paginated":true},{"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":"List subfolders and folder metadata for a specific folder in a project","guidance":["one folder's detail + its direct subfolders + ancestors"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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"},{"name":"per_page","type":"integer"},{"name":"required[fields]","type":"string","example":"owner,priority"},{"name":"required[custom_fields]","type":"string","example":"121,119"}],"intent":"List the test cases in one folder","guidance":["Page through the cases directly inside a folder, by the folder's INTEGER id","Use this when the user has already named or selected a folder","Rows are verbose and list reads truncate around 30 KB"],"shape":"discovered","max_page_size":100,"paginated":true},{"method":"GET","path":"/api/v1/projects","mode":"read","entity":"project","query":[{"name":"q","type":"string","example":"nishchay3"},{"name":"p","type":"integer"},{"name":"sort_by","type":"string"},{"name":"starred","type":"boolean"},{"name":"shared","type":"boolean"},{"name":"is_hidden_projects","type":"boolean","example":true}],"intent":"List projects for a group.","guidance":["Paginated list of the group's projects","query and page are not read by the server","so use this to FIND a project, not to enumerate them"],"returns":["id","identifier","name","created_at","description","import_id","project_creator","test_cases_count","test_runs_count"],"paginated":true},{"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"},{"name":"q[scheduled]","type":"string","values":["true","false"]},{"name":"p","type":"integer"}],"intent":"List all the scheduled reports","guidance":["list the project's report schedules (paged p","Retrieve a paginated list of schedules"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"],"paginated":true},{"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"},{"name":"q","type":"string"}],"intent":"List test-case tags (paginated).","guidance":["list a project's test-case tags (paged with p, optional q)"],"shape":"discovered","paginated":true},{"method":"GET","path":"/api/v1/admin-v2/settings/templates","mode":"read","entity":"","query":[{"name":"entity_type","type":"string","values":["TestCase","TestResult","TestPlan","ExploratorySession"]},{"name":"project","type":"integer"},{"name":"name","type":"string"},{"name":"p","type":"integer"}],"intent":"List the workspace's test-case templates \u2014 THE source for `template_id`.","guidance":["Ids are per-workspace","so one carried over from another workspace (or from an example) produces the generic 400 \"The API request is invalid or improperly formed\" on a test-case create, with nothing naming the offending field","Call this when the user asked for a named template, or to confirm a template id before reusing one","One call therefore yields both fields","NOTE THE CASING: entity_type here is CamelCase (TestCase), unlike the hyphenated test-case used in path segments elsewhere","Passing test-case returns an empty list with a 200"],"shape":"discovered","paginated":true},{"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"},{"name":"include_sub_plans","type":"boolean"},{"name":"status","type":"string","values":["new","started","completed"]},{"name":"sort_by","type":"string"},{"name":"minify","type":"boolean"}],"intent":"List test plans in a project (optionally including sub-plans)","guidance":["include_sub_plans=true to see sub-plans too","Paginated list of the project's test plans","This is how you find a plan's integer id before reading, updating, or deleting it","so never try to find a plan through search","Without it you see only top-level plans and a plan's children are invisible"],"shape":"discovered","paginated":true},{"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"]},{"name":"field_source","type":"string","values":["system","custom","all"]},{"name":"field_type","type":"string"},{"name":"q","type":"string"},{"name":"p","type":"integer"}],"intent":"THE canonical workspace field list (system + custom) \u2014 start here for definitions.","guidance":["workspace-wide list of DEFINITIONS across all projects","'does a field called X exist anywhere?'","The authoritative list (testhub-backed)","This is the authoritative definition list: it is backed by testhub, which owns custom-field definitions","That legacy collection reads a DEPRECATED table local to the Rails app that nothing else consults","its contents are unrelated to the real fields"],"shape":"discovered","paginated":true},{"method":"POST","path":"/api/v1/projects/{project_id}/tags/merge","mode":"write","entity":"tag","path_params":[{"name":"project_id","type":"integer","required":true}],"body":[{"name":"source_id","type":"integer","required":true},{"name":"target_id","type":"integer","required":true},{"name":"entity_type","type":"string","required":true,"values":["test_case","test_run","test_plan","shared_step"]}],"intent":"Merge one tag into another (v1). Admin-gated (tags_management).","guidance":["merge one tag into another ({ source_id, target_id, entity_type })"]},{"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","required":true,"example":123,"description":"ID of the new parent folder (internal APIs)"},{"name":"new_base_project_id","type":"integer","example":456,"description":"Optional destination project ID for cross-project moves"},{"name":"user_action","type":"string","example":"move_folder"}],"intent":"Move a test case folder to a different parent folder","guidance":["move a folder ({ new_base_folder_id, new_base_project_id })","cross-project moves run async","Move a test case folder to a new parent folder within the same project","This operation updates the folder's hierarchy without changing its contents"],"returns":["async","unique_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","required":true,"description":"Name of the new folder.","json_path":"/folder/name"},{"name":"notes","type":"string","description":"Description of the new folder.","json_path":"/folder/notes"}],"intent":"rename a folder in a project","guidance":["Rename an existing folder within a specific project"],"returns":["request_trace_id"]},{"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],"description":"List of folder IDs to reorder"},{"name":"prev","type":"integer","example":101,"description":"ID of the folder that will precede the moved folder"},{"name":"next","type":"integer","example":103,"description":"ID of the folder that will follow the moved folder"}],"intent":"Reorder folders in a project","guidance":["Reorder folders within a project by specifying the current order and optionally the previous and next folder IDs"],"returns":["request_trace_id"]},{"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":"ID of the test case that will be above the moved ones.","json_path":"/re_order/top_test_case"},{"name":"bottom_test_case","type":"string","description":"ID of the test case that will be below the moved ones.","json_path":"/re_order/bottom_test_case"},{"name":"page","type":"integer","description":"Page number for paginated reordering.","json_path":"/re_order/page"},{"name":"test_cases","type":"array","required":true,"description":"Array of test case IDs to be moved.","json_path":"/re_order/test_cases"}],"intent":"Reorder test cases within a folder of a project","guidance":["Reorders test cases in a folder by placing them between top_test_case and bottom_test_case"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"],"paginated":true},{"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. Passed in the BODY, not the path."},{"name":"merge_action","type":"string","required":true,"description":"`merge` folds source into target; any other value archives."},{"name":"source_testcase_id","type":"integer","description":"Required for merge \u2014 the case that will cease to exist independently."},{"name":"target_testcase_id","type":"integer","description":"Required for merge \u2014 the case that survives."}],"intent":"Resolve a duplicate by MERGING or ARCHIVING \u2014 destroys or hides a test case","guidance":["RESOLVE a suggestion","merge_action=merge folds source_testcase_id into target_testcase_id (DESTRUCTIVE to a test case), anything else archives","duplicate_id goes in the BODY","Act on one dedupe recommendation","merge_action selects what happens: - \"merge\"","folds source_testcase_id into target_testcase_id"]},{"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":"Restore a specific history entry","guidance":["roll the case back to this version (PAID plan","Restore a specific history entry by its ID"]},{"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":"page","type":"integer"},{"name":"duplicates","type":"string","description":"Duplicate-type filter."},{"name":"owner","type":"string","description":"Owner tab \u2014 scopes to the caller's own duplicates vs all."}],"intent":"Filtered search over suggested duplicates","guidance":["filtered search over suggestions (owner tab, duplicate type, test-case attributes)","Subject to the identical flag caveat: an empty list may mean the feature is off rather than that there are no duplicates"],"shape":"discovered","paginated":true},{"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"}],"query":[{"name":"q","type":"string"},{"name":"p","type":"integer"}],"intent":"Search group tags for an entity type","guidance":["search tags across the whole group (entity_type = test-case|test-run|test-plan)","Retrieve a paginated list of tags for the given entity type across the group (no project scoping)","used by the Global Search filter dropdowns"],"returns":["name","created_at","usage_count"],"paginated":true},{"method":"GET","path":"/api/v1/projects/{project_id}/{entity}/search","mode":"read","entity":"project","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"entity","type":"string","required":true,"values":["test-cases","test-runs","test-plans","reports"]}],"query":[{"name":"q[query]","type":"string","example":"tc"},{"name":"q[folder_id]","type":"integer","example":29529465},{"name":"use_bstack_id","type":"string","values":["0","1"]},{"name":"p","type":"integer"},{"name":"page_size","type":"integer"},{"name":"include_test_plan","type":"boolean"}],"intent":"Search for entities within a project","guidance":["Returns paginated results matching the search criteria"],"shape":"discovered","max_page_size":100,"paginated":true},{"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"},{"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 "},{"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":["Note: Array values in the q parameter are automatically converted to comma-separated strings for compatibility with existing search logic"],"shape":"discovered","max_page_size":100,"paginated":true},{"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"},{"name":"p","type":"integer"},{"name":"page_size","type":"integer"}],"intent":"Search users with access to a project","guidance":["Retrieves a paginated list of users who have access to the specified project","Returns user details including permissions, feature flags, and product roles"],"returns":["id","browserstack_user_id","customisable_dashboard_accessible","full_name","group_id","has_access","is_build_listing_enabled","is_delighted_visible","is_jira_app_project_panel_visible","is_lcnc_explore_dismissed","is_new_project_settings_visible","is_onboarding_project_header_visible"],"max_page_size":100,"paginated":true},{"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"},{"name":"p","type":"integer"},{"name":"page_size","type":"integer"}],"intent":"Search test case tags","guidance":["Retrieves a paginated list of tags associated with test cases in the project","Returns both simple tag strings and detailed tag objects with metadata"],"returns":["name","created_at","usage_count"],"max_page_size":100,"paginated":true},{"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":"query","type":"string"},{"name":"use_bstack_id","type":"string","values":["0","1"]}],"intent":"Search test cases in a project","guidance":["and see pagination-and-filtering for the key table and each value's source","there is NO top-level values array, and looking for one finds nothing","so it is routinely cut off and appears absent","NEVER probe candidate integers for an id","The four system option fields","a name silently returns 0 because the server matches an id column"],"returns":["id","identifier","name","case_type_imported","comments_count","created_at","description","estimate","expected_result","history_count","is_automation","owner"]},{"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","example":["qa-leads@company.com"],"description":"Recipient addresses. MUST be an array, even for one address."},{"name":"file_type","type":"array","example":["pdf"],"description":"Formats to attach. MUST be an array. Defaults to [\"pdf\"]."}],"intent":"Email a report immediately (async job).","guidance":["Kicks off a background send","a 200 means the job was queued, NOT that mail was delivered","don't report it as sent","Both body fields are ARRAYS and are rejected with 400 \" should be an array\" if sent as scalars","Omitting emails sends to the report's configured recipients","omitting file_type defaults to [\"pdf\"]"]},{"method":"POST","path":"/api/v1/projects/{project_id}/folder/{folder_id}/undo-rm","mode":"write","entity":"folder","path_params":[{"name":"project_id","type":"integer","required":true},{"name":"folder_id","type":"integer","required":true}],"intent":"Undo folder deletion","guidance":["Restores a previously deleted folder and returns its metadata along with path hierarchy"],"returns":["id","name","cases_count","group_id","is_automation","project_id","sub_folders_count","total_cases_count"]},{"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":"dataset_id","type":"integer","required":true},{"name":"option_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"body":[{"name":"option_value","type":"string","required":true,"description":"The option label."},{"name":"is_default","type":"boolean","required":true,"description":"REQUIRED \u2014 a missing value is a 400. Must be 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 parent option this hangs under."}],"intent":"Rename an option or change its default (POST to /update).","guidance":["NON-destructive, stored values follow the option id","Both option_value and is_default are required","a missing is_default is a 400","Renaming an option keeps the stored values pointing at it (the option id is unchanged)","so this is the NON-destructive way to fix a label"]},{"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":"dataset_id","type":"integer","required":true},{"name":"custom_fields_id","type":"integer","required":true}],"body":[{"name":"link_projects","type":"array","description":"Project INTEGER ids to ADD. Note the spelling \u2014 not `linked_projects`."},{"name":"unlink_projects","type":"array","description":"Project INTEGER ids to REMOVE. Destroys their stored values."},{"name":"link_to_future_projects","type":"boolean"},{"name":"select_all","type":"boolean","description":"Apply to every project; may switch the call to async."}],"intent":"Link or unlink projects for a dataset \u2014 how you change which projects see a field.","guidance":["Unlinking destroys that project's values","The keys here are link_projects and unlink_projects","Send the wrong spelling and it is silently dropped: 200, nothing changed","This is the single easiest mistake on this surface","These are DELTAS, not the complete new list: send only the projects to add and the projects to remove","Unlinking is destructive"]},{"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,"description":"Display label. Editable. REQUIRED even when unchanged."},{"name":"field_type","type":"string","required":true,"description":"REQUIRED for validation, but any change is silently ignored."},{"name":"is_required","type":"boolean","required":true,"description":"REQUIRED \u2014 omitting it is a 400."},{"name":"entity_type","type":"string"},{"name":"place_holder_text","type":"string"},{"name":"default_value","type":"string","description":"Only applied when `field_type` is `boolean`."}],"intent":"Update a field definition's label, requiredness or placeholder (POST to /update).","guidance":["Full replace: field_name + field_type + is_required all required","field_type changes are silently ignored","field_name, field_type and is_required must all be present","Omitting is_required is 400 \"Invalid Params\"","omitting field_name is a 500","field_name IS editable here"]},{"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","description":"Updated name of the dataset.","json_path":"/dataset/name"},{"name":"variables","type":"array","description":"Updated list of dataset variables/columns (max 40).","fields":[{"name":"name","type":"string","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/variables"},{"name":"rows","type":"array","description":"Updated list of dataset rows (max 100).","fields":[{"name":"row_number","type":"integer","required":true},{"name":"row_data","type":"array","required":true},{"name":"uuid","type":"string"}],"json_path":"/dataset/rows"}],"intent":"Update a dataset.","guidance":["Update an existing dataset by its UUID with new variables and rows"],"returns":["name","created_at","rows_count","uuid"]},{"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 for the session.","json_path":"/exploratory_session/title"},{"name":"description","type":"string","description":"Updated description.","json_path":"/exploratory_session/description"},{"name":"charter","type":"string","description":"Updated testing charter.","json_path":"/exploratory_session/charter"},{"name":"timebox_duration","type":"integer","example":90,"description":"Updated planned duration in minutes.","json_path":"/exploratory_session/timebox_duration"},{"name":"duration","type":"integer","example":45,"description":"Tracked duration in minutes.","json_path":"/exploratory_session/duration"},{"name":"actual_duration","type":"integer","example":50,"description":"Final actual duration in minutes.","json_path":"/exploratory_session/actual_duration"},{"name":"folder_id","type":"integer","example":789,"description":"Move the session to a different folder.","json_path":"/exploratory_session/folder_id"},{"name":"owner","type":"integer","example":55,"description":"TCM user ID of the new owner / assignee.","json_path":"/exploratory_session/owner"},{"name":"assignee","type":"integer","example":55,"description":"Alias for `owner`.","json_path":"/exploratory_session/assignee"},{"name":"tags","type":"array","example":["smoke","payment"],"description":"Replace the session's tags with this list.","json_path":"/exploratory_session/tags"},{"name":"configurations","type":"array","example":[2,4],"description":"Replace the session's configuration IDs.","json_path":"/exploratory_session/configurations"},{"name":"attachments","type":"array","description":"Updated set of blob / media attachment IDs.","json_path":"/exploratory_session/attachments"},{"name":"issues","type":"array","description":"Replace the linked issues for this session.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/exploratory_session/issues"}],"intent":"Update an exploratory session","guidance":["edit a session (title, charter, timebox_duration, duration, owner, tags, ...)","Updates one or more fields of an exploratory session","Time fields (timebox_duration, duration, actual_duration) must be non-negative integers not exceeding 2147483647"],"returns":["id","title","actual_duration","charter","created_at","description","duration","folder_id","session_log_count","session_state","timebox_duration","updated_at"]},{"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":"Updated rich-text HTML content of the log entry.","json_path":"/session_log/content"},{"name":"status","type":"string","values":["pass","fail","bug","retest","block","note"],"example":"fail","description":"Updated test result status.","json_path":"/session_log/status"},{"name":"elapsed_time","type":"integer","example":180,"description":"Updated elapsed time in seconds.","json_path":"/session_log/elapsed_time"},{"name":"defects","type":"array","description":"Replace the linked defects for this log entry.","fields":[{"name":"issue_id","type":"string","required":true},{"name":"issue_type","type":"string","required":true}],"json_path":"/session_log/defects"}],"intent":"Update a session log entry","guidance":["Updates an existing log entry within an exploratory session","Rich-text HTML content is sanitised before storage"],"returns":["id","status","content","created_at","elapsed_time","session_id","updated_at"]},{"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,"description":"Name of the filter"},{"name":"entity","type":"string","required":true,"values":["testcase","testplan","testrun","exploratory_session","test_plan_test_case"],"description":"Entity type the filter applies to. The server's validator accepts five values (the\nlast two were missing here); an unlisted value returns 400 \"Invalid JSON data\"."},{"name":"is_project_level","type":"boolean","description":"Whether this filter is available at project level"},{"name":"filters","type":"object","description":"Filter criteria object containing the actual filter conditions"}],"intent":"Update a filter","guidance":["Update an existing saved filter with new configuration or filter criteria"],"returns":["id","name","created_at","entity_type","is_project_level","owner","project_id","updated_at"]},{"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"},{"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 these are ids, not names).","json_path":"/test_result/status_id"},{"name":"description","type":"string","description":"Rich text description of the test result.","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's 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":"Update the latest test result","guidance":["update the LATEST result for a case in a run (partial, same body shape)","Update the most recent test result for a specific test case within a test run in a project","Optionally filter by configuration_id or mapping_id"],"returns":["id","name","byte_size","checksum","content_type","download_url","key","product_id","size","url"]},{"method":"POST","path":"/api/v1/projects/{project_id}/settings","mode":"write","entity":"project","path_params":[{"name":"project_id","type":"integer","required":true}],"intent":"Update project settings","guidance":["Accepts an object with setting keys as properties and boolean values"]},{"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","example":10000},{"name":"report_type","type":"string","required":true,"values":["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","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_statu"},{"name":"title","type":"string","example":"Updated Title"},{"name":"description","type":"string","example":""},{"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 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`"},{"name":"report_creation_mode","type":"string","required":true,"values":["custom_report","system_generated"],"example":"custom_report"},{"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"},{"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":"Integer run ids \u2014 pairs with report_timeframe=specific_test_runs.","json_path":"/report_filters/test_runs"},{"name":"test_plans","type":"array","description":"Integer plan ids \u2014 pairs with report_timeframe=specific_test_plans.","json_path":"/report_filters/test_plans"},{"name":"test_cases","type":"string","description":"Case selection \u2014 pairs with report_timeframe=specific_test_cases. FOLDER-KEYED\n(see SelectionData), never a flat id list: the server resolves the selection\nthrough its folder map, so any other shape yields zero cases and saves an\nUNSCOPED, project-wide report at 200.\n\nSend all three keys. Folder ids are STRING keys; test-case ids are INTEGERS\n(the numeric `id`, not the TC-NNN identifier) \u2014 take bo","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":"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.","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 } }.","json_path":"/report_filters/test_plan_selection"},{"name":"builds","type":"array","json_path":"/report_filters/builds"},{"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\n`Test Run Summary`, `Test Run Detailed Report` and `Test Case Activity` \u2014 on\nthe other six report types the server never reads it and drops it silently.\nPersisted (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`."},{"name":"users","type":"array","json_path":"/mail_to/users"},{"name":"external_mails","type":"array","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\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"},{"name":"time","type":"string","example":"08:00","description":"24-hour HH:MM, UTC.","json_path":"/frequency_details/time"},{"name":"day","type":"string","example":"monday","description":"Required for weekly (lowercase day name) and monthly (integer 1-28).\nOmit for daily.","json_path":"/frequency_details/day"}],"intent":"Update a scheduled report","guidance":["FULL REPLACE, not a delta: omitted fields are nulled and dropping frequency cancels the schedule","Update the configuration of an existing scheduled report for a project"],"returns":["id","identifier","title","created_at","custom_date_range","description","frequency","group_id","next_run_at","project_id","report_creation_mode","report_timeframe"]},{"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":"Title of the shared step"},{"name":"folder_id","type":"integer","description":"ID of the shared-field folder to move the shared step into. Null moves it to the root (Unassigned)."},{"name":"shared_step_details","type":"array","required":true,"fields":[{"name":"id","type":"integer"},{"name":"step","type":"string"},{"name":"result","type":"string"},{"name":"order","type":"integer"},{"name":"test_data","type":"string"}]}],"intent":"Update a shared step","guidance":["title and shared_step_details are BOTH required, and the details array REPLACES the existing steps","Updates an existing shared step in a project"],"returns":["id","title"]},{"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"},{"name":"description","type":"string","json_path":"/test_plan/description"},{"name":"start_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/start_date"},{"name":"end_date","type":"string","description":"ISO-8601 date.","json_path":"/test_plan/end_date"},{"name":"plan_status","type":"string","description":"Plan status. The list filter accepts new / started / completed.","json_path":"/test_plan/plan_status"},{"name":"owner","type":"integer","description":"Owner user id \u2014 resolve via the project users read first.","json_path":"/test_plan/owner"},{"name":"parent_plan_id","type":"integer","description":"Parent plan's INTEGER id. Set it to create (or re-parent into) a SUB-plan \u2014 the\nresult carries an STP-NNN identifier. Null/omitted means a top-level plan.","json_path":"/test_plan/parent_plan_id"},{"name":"tags","type":"array","json_path":"/test_plan/tags"},{"name":"reviewers","type":"array","description":"Reviewer user ids.","json_path":"/test_plan/reviewers"},{"name":"attachments","type":"array","description":"Attachment ids already uploaded via the attachments surface.","json_path":"/test_plan/attachments"},{"name":"custom_fields","type":"object","json_path":"/test_plan/custom_fields"},{"name":"issues","type":"array","description":"Linked tracker issues. Structured \u2014 NOT a flat array of keys.","fields":[{"name":"issue_id","type":"string"},{"name":"issue_type","type":"string"},{"name":"metadata","type":"object"}],"json_path":"/test_plan/issues"},{"name":"test_runs","type":"string","description":"The plan's linked test runs. Accepts EITHER an array of test-run integer ids, OR a\nbulk-selection object for \"every run matching this filter\". On update this REPLACES\nthe existing link set rather than appending.","json_path":"/test_plan/test_runs"}],"intent":"Update a test plan (or sub-plan)","guidance":["Update a plan by its INTEGER id","Takes the same body as create","so it also works on a sub-plan by its own id","clearing it promotes a sub-plan to a top-level plan","do that only when the user actually asked to move it in the hierarchy","Send only the fields you intend to change"]},{"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 AI-generated or AI-processed attachments","guidance":["upload a file as AI CONTEXT (restricted MIME types, smaller size cap) to seed test-case content","Files are stored in S3 with pre-signed URLs","File Storage: Files uploaded to S3 with pre-signed URLs (expires in ~26 minutes)"],"returns":["id","name","content_type","download_url","size","url"]},{"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 generic attachments to a project from rich text editor or other sources","guidance":["UPLOAD file blob(s) (multipart)","Uploads one or more files as generic attachments to a project","Files are stored in S3 and pre-signed URLs are returned for both viewing and downloading"],"returns":["id","name","content_type","download_url","size","url"]},{"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":"Upload one or more attachments to a test case","guidance":["Upload one or more attachments to a specific test case within a folder in a project"],"returns":["id","byte_size","content_type","filename"]},{"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 whose per-test-case / per-run / per-day breakdown is requested."},{"name":"p","type":"integer","example":1,"description":"Page number for paginated drill-down rows."},{"name":"per_page","type":"integer","example":50,"description":"Number of rows per page."}],"intent":"Fetch the User Workload drill-down for a single user","guidance":["User-Workload per-user execution breakdown","Only valid on a report whose type is User Workload Report","every other type returns 400 \"Drill-down is only available for User Workload Reports\""],"shape":"discovered","max_page_size":100,"paginated":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},"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)","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_v1"]},"attachment":{"entity":"attachment","title":"Attachments","aliases":["attachment","file","attachments","blob"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"attached to"},{"entity":"result","via":"attached to"}],"capabilities":["delete_test_case_attachment","get_test_case_attachments_v1","upload_a_i_attachments","upload_generic_attachments","upload_test_case_attachments_v1"]},"configuration":{"entity":"configuration","title":"Configurations","aliases":["config","configuration","environment","browser","os","device"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_run","via":"case status tracked against"}],"capabilities":["create_configuration_v1","get_configurations_v1"]},"custom_field":{"entity":"custom_field","title":"Custom fields & system fields","aliases":["custom field","custom fields","field","system field"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"extends"},{"entity":"result","via":"extends"}],"capabilities":["create_custom_field_dataset_option_v2","create_custom_field_dataset_v2","create_custom_field_definition_v2","delete_custom_field_dataset_option_v2","delete_custom_field_dataset_v2","delete_custom_field_definition_v2","get_custom_field_dataset_v2","get_custom_field_definition_v2","get_custom_field_values_v1","get_project_id_custom_fields_v2_v1","get_test_results_custom_fields_v1","list_custom_field_dataset_options_v2","list_workspace_fields_v2","update_custom_field_dataset_option_v2","update_custom_field_dataset_project_mapping_v2","update_custom_field_definition_v2"]},"dataset":{"entity":"dataset","title":"Dataset","aliases":["datasets","data-driven-data","dds"],"id_convention":"uuid","parents":["project"],"relations":[{"entity":"test_case","via":"drives"},{"entity":"variable","via":"has columns"}],"capabilities":["create_dataset","delete_dataset","get_dataset","get_dataset_linked_test_cases","get_dataset_variables","import_dataset_c_s_v","list_datasets","update_dataset"]},"duplicate":{"entity":"duplicate","title":"Duplicate recommendation (AI dedupe)","aliases":["duplicate","duplicates","dedupe","deduplication","duplicate recommendation","ai duplicates","redundant test cases"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"links the pair it believes are duplicates"}],"capabilities":["discard_duplicate_v1","get_dedupe_status_v1","get_duplicate_source_test_case_v1","get_duplicate_v1","list_duplicates_v1","resolve_duplicate_v1","search_duplicates_v1"]},"exploratory_session":{"entity":"exploratory_session","title":"Exploratory session","aliases":["session","exploratory","et-session"],"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_v1","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"]},"filter":{"entity":"filter","title":"Saved filter view","aliases":["filter","filters","saved view","saved 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"]},"folder":{"entity":"folder","title":"Folder","aliases":["dir","directory"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"folder","via":"nests under"},{"entity":"test_case","via":"organises"}],"capabilities":["copy_folder","create_bulk_test_cases_v1","create_root_folder_v1","create_sub_folder_v1","delete_folder_and_contents","get_folder_remove_summary","get_folder_tree_v1","get_root_folders_v1","list_folder_contents","move_folder_v1","rename_folder_v1","reorder_folders","undo_remove_folder"]},"project":{"entity":"project","title":"Project","aliases":["pr","workspace"],"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_v1","export_dashboard_analytics","get_active_test_runs_info_v1","get_automation_stats_v1","get_closed_test_runs_info_v1","get_closed_test_runs_split_v1","get_entity_filter_details_v1","get_issues_count_info_v1","get_project_by_integer_id_v1","get_project_settings","get_project_users_v2_v1","get_projects_basic_v1","get_projects_minify_v1","get_test_case_count_trend_v1","get_test_case_type_split_v1","list_projects_v1","search_project_entities","update_project_settings"]},"report":{"entity":"report","title":"Report","aliases":["reports","scheduled-report","schedule","analytics-report"],"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","delete_report_schedule","download_report","get_exploratory_session_summary_v1","get_report_attachment_url","get_report_detail","get_report_section_v1","get_report_summary_by_type","get_selected_report_testcases","list_schedules","send_report_email_now_v1","update_report","user_workload_drilldown"]},"result":{"entity":"result","title":"Result","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_v1","create_step_result_v1","create_test_result_for_test_case","delete_test_result_v1","get_test_results_for_test_case","update_latest_test_result_for_test_case"]},"shared_step":{"entity":"shared_step","title":"Shared step","aliases":["shared step","shared steps","reusable step","step template"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"test_case","via":"reused by"}],"capabilities":["create_shared_step_v1","delete_shared_step_v1","get_shared_steps_by_i_d_v1","get_shared_steps_v1","update_shared_step_v1"]},"tag":{"entity":"tag","title":"Tag","aliases":["tags","label","labels"],"id_convention":"name","parents":["project"],"relations":[{"entity":"test_case","via":"labels"},{"entity":"test_run","via":"labels"},{"entity":"test_plan","via":"labels"}],"capabilities":["bulk_edit_test_cases_v2","get_test_case_tags","list_test_case_tags_v1","merge_tags_v1","search_group_tags","search_test_case_tags"]},"test_case":{"entity":"test_case","title":"Test case","aliases":["tc","case","test case"],"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":["bulk_archive_test_cases_by_project","bulk_copy_test_cases","bulk_delete_test_cases","bulk_edit_test_cases","bulk_edit_test_cases_in_test_run","bulk_move_test_cases","bulk_retrieve_test_cases","create_test_case_v1","create_test_cases","edit_test_case_partial_v1","edit_test_case_v1","get_archived_test_cases","get_system_field_values_v1","get_test_case_by_integer_id_v1","get_test_case_detail","get_test_cases_v1","list_folder_test_cases_v1","reorder_test_cases_by_folder_v1","search_project_entities_v2","search_test_cases_by_folder_v1"]},"test_plan":{"entity":"test_plan","title":"Test plan (and sub-plan)","aliases":["tp","plan","milestone","sub test plan","subplan","stp"],"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_v1","bulk_retrieve_test_plans_v1","clone_test_plan_v1","count_test_plan_test_runs_v1","create_test_plan_v1","delete_test_plan_v1","get_archived_test_plan_count_v1","get_test_plan_by_integer_id_v1","get_test_plan_execution_trend_v1","get_test_plan_linked_sessions_chart_data_v1","get_test_plan_test_runs_v1","list_archived_test_plans_v1","list_test_plans_v1","update_test_plan_v1"]},"test_run":{"entity":"test_run","title":"Test run","aliases":["tr","run","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":["assign_test_run_owner","bulk_assign_test_cases_to_test_run","clone_test_run","close_test_run_v1","count_run_selection_v1","create_test_run_v1","delete_v1_test_run","edit_test_run","get_closed_test_runs","get_test_cases_for_v1_test_run","get_test_run_by_integer_id_v1","get_test_run_cases_v1","get_test_runs_form_fields_v1","get_test_runs_selection","get_test_runs_v1"]},"user":{"entity":"user","title":"User","aliases":["users","member","assignee","owner"],"id_convention":"integer","parents":["project"],"relations":[{"entity":"project","via":"member of"},{"entity":"test_run","via":"assigned to"},{"entity":"test_case","via":"owns"}],"capabilities":["get_group_users_v2_v1","search_project_users"]},"version":{"entity":"version","title":"Test case version","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":["get_test_case_histories_v1","get_test_case_history_diff_v1","get_test_case_history_v1","restore_history_v1"]}}}}} \ No newline at end of file diff --git a/tests/tools/capabilityRegistry.test.ts b/tests/tools/capabilityRegistry.test.ts deleted file mode 100644 index 8550256..0000000 --- a/tests/tools/capabilityRegistry.test.ts +++ /dev/null @@ -1,240 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { bind, coerce } from "../../src/tools/capability-registry/bind.js"; -import { authHeaders } from "../../src/tools/capability-registry/egress.js"; -import { - CapabilityRegistry, InvocationError, IndexError, -} from "../../src/tools/capability-registry/index-loader.js"; -import { invoke } from "../../src/tools/capability-registry/resolve.js"; -import { isCollection, searchCapabilities, terms } from "../../src/tools/capability-registry/search.js"; -import { Capability, RegistryIndex } from "../../src/tools/capability-registry/types.js"; - -const LIST_CASES: Capability = { - method: "GET", path: "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases", - mode: "read", entity: "test_case", paginated: true, max_items: 300, - intent: "List the test cases in one folder", - path_params: [ - { name: "project_id", type: "integer", required: true }, - { name: "folder_id", type: "integer", required: true }, - ], - query: [{ name: "p", type: "integer" }, { name: "count", type: "integer" }], - returns: ["id", "identifier", "title"], -}; - -const CREATE_FOLDER: Capability = { - method: "POST", path: "/api/v1/projects/{project_id}/folders", - mode: "write", entity: "folder", - path_params: [{ name: "project_id", type: "integer", required: true }], - // the nesting the product really wants, which a reader of the flat spec would miss - body: [ - { name: "name", type: "string", required: true, json_path: "/folder/name" }, - { name: "notes", type: "string", json_path: "/folder/notes" }, - ], - returns: ["id", "name"], -}; - -const DELETE_PLAN: Capability = { - 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 }, - ], -}; - -const BULK_MOVE: Capability = { - 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 }], - // the collision that makes a flat argument map ambiguous - body: [{ name: "folder_id", type: "integer" }], -}; - -const INDEX: RegistryIndex = { - schema_version: 1, build_id: "abc123-173caps", - products: { - tm: { - summary: "Test Management", - capabilities: [LIST_CASES, CREATE_FOLDER, DELETE_PLAN, BULK_MOVE], - entities: { test_case: { aliases: ["tc", "case"] }, folder: {}, test_plan: {} }, - }, - }, -}; - -describe("index loader", () => { - it("refuses an index whose schema it does not understand", () => { - // Guessing at a shape the generator announced is exactly where silently wrong tool - // output comes from. - expect(() => new CapabilityRegistry({ ...INDEX, schema_version: 99 })) - .toThrow(IndexError); - }); - - it("finds a capability by endpoint, and says so when it cannot", () => { - const registry = new CapabilityRegistry(INDEX); - expect(registry.byEndpointLookup("get", LIST_CASES.path).capability).toBe(LIST_CASES); - expect(() => registry.byEndpointLookup("GET", "/api/v1/nope")) - .toThrow(/unknown_endpoint/); - }); -}); - -describe("binding", () => { - it("substitutes path params and keeps query separate", () => { - const bound = bind(LIST_CASES, { path_params: { project_id: 2, folder_id: 7 } }); - expect(bound.path).toBe("/api/v1/projects/2/folder/7/test-cases"); - expect(bound.body).toBeUndefined(); - }); - - it("builds the nested body the product expects, not the flat one", () => { - const bound = bind(CREATE_FOLDER, { - path_params: { project_id: 2 }, body: { name: "New", notes: "d" }, - }); - expect(bound.body).toEqual({ folder: { name: "New", notes: "d" } }); - }); - - it("keeps a name declared in two places unambiguous", () => { - // `folder_id` is a body field here while `project_id` is a path one; grouping is what - // makes that expressible at all. - const bound = bind(BULK_MOVE, { path_params: { project_id: 2 }, body: { folder_id: 9 } }); - expect(bound.path).toBe("/api/v1/projects/2/test-cases/bulk-move"); - expect(bound.body).toEqual({ folder_id: 9 }); - }); - - it("refuses an unknown argument instead of dropping it", () => { - // Silently ignoring a misspelled filter returns a larger result set that looks correct. - expect(() => bind(LIST_CASES, { path_params: { project_id: 1, folder_id: 1 }, query: { pp: 1 } })) - .toThrow(/unknown query: pp/); - }); - - it("enforces required body fields, not just path ones", () => { - expect(() => bind(CREATE_FOLDER, { path_params: { project_id: 2 }, body: {} })) - .toThrow(/missing required parameter\(s\): name/); - }); - - it("stops a traversal attempt at the declared type", () => { - expect(() => bind(LIST_CASES, { path_params: { project_id: "../../admin-v2", folder_id: 1 } })) - .toThrow(/must be a number/); - }); - - it("encodes a string path value so it cannot rewrite the route", () => { - const capability: Capability = { - ...LIST_CASES, path: "/api/v1/x/{slug}", - path_params: [{ name: "slug", type: "string", required: true }], query: [], - }; - expect(bind(capability, { path_params: { slug: "a/b" } }).path).toBe("/api/v1/x/a%2Fb"); - }); - - it("checks enums", () => { - expect(() => coerce("nope", { name: "s", type: "string", values: ["low", "high"] })) - .toThrow(/must be one of: low, high/); - }); -}); - -describe("search", () => { - it("does not treat verbs as stopwords", () => { - expect(terms("list the test cases")).toEqual(["list", "test", "cases"]); - }); - - it("prefers a collection for a plural query", () => { - expect(isCollection(LIST_CASES)).toBe(true); - expect(isCollection(DELETE_PLAN)).toBe(false); - }); - - it("matches through the aliases the harness authored", () => { - const hits = searchCapabilities(INDEX.products, "tc"); - expect(hits.capabilities.map((c) => c.entity)).toContain("test_case"); - }); - - it("ranks a write query onto the write endpoint", () => { - const hits = searchCapabilities(INDEX.products, "create a folder"); - expect(hits.capabilities[0].path).toBe(CREATE_FOLDER.path); - }); - - it("lets a penalty reorder without excluding", () => { - // A cardinality penalty used to take a valid score to zero, and 40 legitimate matches - // vanished — the caller saw "no such capability". - const hits = searchCapabilities(INDEX.products, "list test cases"); - expect(hits.total_matched).toBeGreaterThan(1); - }); -}); - -describe("auth", () => { - it("forwards the caller's credentials as Api-Token", () => { - // HTTP Basic is not usable on /api/v1; Api-Token is what the whole surface accepts. - const headers = authHeaders({ username: "ing_Xx", accessKey: "SECRET" }); - expect(headers["Api-Token"]).toBe("ing_Xx:SECRET"); - expect(headers["request-source"]).toBe("ai-chatbot"); - }); - - it("refuses rather than sending unauthenticated", () => { - expect(() => authHeaders({ username: "u", accessKey: "" })).toThrow(InvocationError); - }); -}); - -describe("invoke — one request, response returned untouched", () => { - const credentials = { username: "u", accessKey: "k" }; - - it("makes exactly ONE request and hands back status and body", async () => { - let calls = 0; - const transport = async () => { - calls += 1; - return { - status: 200, - body: { test_cases: [{ id: 1, leaked: "kept" }], info: { count: 880, next: null } }, - }; - }; - const result = await invoke( - LIST_CASES, { path_params: { project_id: 1, folder_id: 2 } }, - "https://tm.example", credentials, transport, - ); - expect(calls).toBe(1); // paging is the caller's now - expect(result.ok).toBe(true); - expect(result.completed).toBe(true); // the envelope says next: null - // No extraction, no counting, no projection — the body as sent, `leaked` included. - expect(result.http_response).toEqual({ - status: 200, - body: { test_cases: [{ id: 1, leaked: "kept" }], info: { count: 880, next: null } }, - }); - }); - - it("says the answer is incomplete when the envelope declares another page", async () => { - const transport = async () => ({ - status: 200, body: { test_cases: [{ id: 1 }], info: { count: 880, next: 2 } }, - }); - const result = await invoke( - LIST_CASES, { path_params: { project_id: 1, folder_id: 2 } }, - "https://tm.example", credentials, transport, - ); - expect(result.ok).toBe(true); - expect(result.completed).toBe(false); - }); - - it("mirrors a non-2xx and lets the product's own body explain it", async () => { - const transport = async () => ({ - status: 422, - body: { success: false, error: "Drill-down is only available for User Workload Reports" }, - }); - const result = await invoke( - LIST_CASES, { path_params: { project_id: 1, folder_id: 2 } }, - "https://tm.example", credentials, transport, - ); - expect(result.ok).toBe(false); - expect(result.completed).toBe(false); - expect(result.http_response.status).toBe(422); - expect((result.http_response.body as Record).error) - .toMatch(/User Workload Reports/); - }); - - it("reports an unreachable product as status 0", async () => { - const transport = async () => ({ - status: 0, body: null, error: "the product could not be reached", - }); - const result = await invoke( - LIST_CASES, { path_params: { project_id: 1, folder_id: 2 } }, - "https://tm.example", credentials, transport, - ); - expect(result.ok).toBe(false); - expect(result.http_response.status).toBe(0); - expect(result.http_response.error).toMatch(/could not be reached/); - }); -}); - diff --git a/tests/tools/capabilityRegistryArtifact.test.ts b/tests/tools/capabilityRegistryArtifact.test.ts deleted file mode 100644 index b930202..0000000 --- a/tests/tools/capabilityRegistryArtifact.test.ts +++ /dev/null @@ -1,67 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { fileURLToPath } from "node:url"; - -import { CapabilityRegistry } from "../../src/tools/capability-registry/index-loader.js"; -import { searchCapabilities } from "../../src/tools/capability-registry/search.js"; -import { bind } from "../../src/tools/capability-registry/bind.js"; - -const FIXTURE = fileURLToPath(new URL("../fixtures/registry-index.json", import.meta.url)); -const registry = CapabilityRegistry.fromFile(FIXTURE); - -describe("the real artifact", () => { - it("loads every capability the Python build emitted", () => { - expect(registry.buildId).toMatch(/caps/); - expect(registry.index.products.tm.capabilities).toHaveLength(173); - expect(Object.keys(registry.index.products.tm.entities)).toHaveLength(19); - }); - - it("carries no internal machinery — the reason we ship an index, not the specs", () => { - const blob = JSON.stringify(registry.index); - for (const forbidden of ["x-atlas-permission", '"target"', '"pointer"', "key_facts", - '"operations"', "strip_prefix", "page_param", "count_param"]) { - expect(blob).not.toContain(forbidden); - } - }); - - it("carries only a harness-declared host, and tm declares none", () => { - // Harness declares the default, config overrides it — the same precedence Atlas uses. - // What must never appear is a host that came from CONFIG, since one artifact ships to - // every environment. tm's product.yaml leaves the host to config on purpose, so that - // per-account region sharding is honoured. - expect(registry.index.products.tm.base_url).toBeUndefined(); - const blob = JSON.stringify(registry.index); - for (const host of ["bsstag.com", "browserstack.com", "https://"]) { - expect(blob).not.toContain(host); - } - }); - - it("answers a real query with a usable endpoint", () => { - const hits = searchCapabilities(registry.index.products, "list the test cases in a folder"); - expect(hits.capabilities.length).toBeGreaterThan(0); - const top = hits.capabilities[0]; - expect(top.method).toBeTruthy(); - expect(top.path.startsWith("/api/")).toBe(true); - expect(top.mode).toBe("read"); - }); - - it("refuses every destructive endpoint before binding", () => { - const destructive = registry.index.products.tm.capabilities - .filter((capability) => capability.mode === "destructive"); - // 16 in tm: 6 real DELETEs plus 10 POSTs whose path ends in delete/rm. - expect(destructive.length).toBe(16); - }); - - it("binds a real endpoint end to end from what search returned", () => { - const { capability } = registry.byEndpointLookup( - "GET", "/api/v1/projects/{project_id}/folder/{folder_id}/test-cases", - ); - const bound = bind(capability, { path_params: { project_id: 379320413, folder_id: 750414 } }); - expect(bound.path).toBe("/api/v1/projects/379320413/folder/750414/test-cases"); - }); - - it("marks the endpoints whose response the product never declared", () => { - const discovered = registry.index.products.tm.capabilities - .filter((capability) => capability.shape === "discovered"); - expect(discovered.length).toBe(32); - }); -}); diff --git a/tests/tools/capabilityRegistryE2E.test.ts b/tests/tools/capabilityRegistryE2E.test.ts deleted file mode 100644 index 680fc48..0000000 --- a/tests/tools/capabilityRegistryE2E.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { fileURLToPath } from "node:url"; - -const FIXTURE = fileURLToPath(new URL("../fixtures/registry-index.json", import.meta.url)); - -const CONFIG = { - "browserstack-username": "ing_Xx", - "browserstack-access-key": "SECRET", -} as any; - -async function buildServer() { - // Imported lazily so the env below is in place before config.ts resolves the artifact. - const { BrowserStackMcpServer } = await import("../../src/server-factory.js"); - return new BrowserStackMcpServer(CONFIG); -} - -describe("capability registry, end to end through the server factory", () => { - beforeEach(() => { - process.env.CAPABILITY_REGISTRY_INDEX = FIXTURE; - delete process.env.CAPABILITY_REGISTRY_DISABLED; - vi.resetModules(); - }); - - afterEach(() => { - delete process.env.CAPABILITY_REGISTRY_INDEX; - delete process.env.CAPABILITY_REGISTRY_BASE_URL_TM; - vi.unstubAllGlobals(); - }); - - it("registers its five tools alongside the hand-written ones", async () => { - const server = await buildServer(); - const tools = server.getTools(); - for (const name of ["listProducts", "listEntities", "describeEntity", - "searchCapability", "invokeEndpoint"]) { - expect(tools[name], name).toBeDefined(); - } - // the existing surface is untouched - expect(tools.listTestCases ?? tools.createTestCase).toBeDefined(); - }); - - it("registers nothing, and does not throw, when the artifact is missing", async () => { - process.env.CAPABILITY_REGISTRY_INDEX = "/nonexistent/index.json"; - const server = await buildServer(); - // A packaging problem must not take every other product's tools down with it. - expect(server.getTools().invokeEndpoint).toBeUndefined(); - expect(Object.keys(server.getTools()).length).toBeGreaterThan(5); - }); - - it("honours the kill switch", async () => { - process.env.CAPABILITY_REGISTRY_DISABLED = "true"; - const server = await buildServer(); - expect(server.getTools().searchCapability).toBeUndefined(); - }); - - it("searches the real index through the registered tool", async () => { - const server = await buildServer(); - const result: any = await (server.getTools().searchCapability as any).handler( - { query: "list the test cases in a folder" }, {} as any, - ); - const payload = JSON.parse(result.content[0].text); - expect(payload.build_id).toMatch(/caps/); - expect(payload.capabilities.length).toBeGreaterThan(0); - expect(payload.capabilities[0].path.startsWith("/api/")).toBe(true); - }); - - it("invokes a real endpoint: forwards Api-Token and returns the response untouched", async () => { - process.env.CAPABILITY_REGISTRY_BASE_URL_TM = "https://tm.example"; - const calls: { url: string; headers: Record }[] = []; - vi.stubGlobal("fetch", async (url: string, init: any) => { - calls.push({ url: String(url), headers: init.headers }); - return { - status: 200, - headers: { get: () => "application/json" }, - json: async () => ({ - projects: [{ id: 1, name: "P", description: "d", leaked: "no" }], - info: { count: 1 }, - }), - }; - }); - - const server = await buildServer(); - const result: any = await (server.getTools().invokeEndpoint as any).handler( - { method: "GET", path: "/api/v1/projects/basic" }, {} as any, - ); - const payload = JSON.parse(result.content[0].text); - - expect(payload.ok).toBe(true); - expect(calls[0].headers["Api-Token"]).toBe("ing_Xx:SECRET"); - expect(calls[0].headers["request-source"]).toBe("ai-chatbot"); - expect(calls[0].url.startsWith("https://tm.example/api/v1/projects/basic")).toBe(true); - // ONE request, and the body exactly as the product sent it - expect(calls).toHaveLength(1); - expect(payload.http_response.status).toBe(200); - expect(payload.http_response.body.projects[0]) - .toEqual({ id: 1, name: "P", description: "d", leaked: "no" }); - }); - - it("refuses a destructive endpoint without calling the product", async () => { - const fetchSpy = vi.fn(); - vi.stubGlobal("fetch", fetchSpy); - const server = await buildServer(); - const result: any = await (server.getTools().invokeEndpoint as any).handler( - { - method: "POST", - path: "/api/v1/projects/{project_id}/test-plans/{test_plan_id}/delete", - path_params: { project_id: 1, test_plan_id: 2 }, - user_permission: "granted", - change_summary: "delete it", - }, - {} as any, - ); - expect(result.isError).toBe(true); - expect(JSON.parse(result.content[0].text).error).toMatch(/destructive/); - expect(fetchSpy).not.toHaveBeenCalled(); // refused before any egress - }); - - it("refuses a write until the user has confirmed, and validates params first", async () => { - const fetchSpy = vi.fn(); - vi.stubGlobal("fetch", fetchSpy); - const server = await buildServer(); - const invokeEndpoint = server.getTools().invokeEndpoint as any; - - const noConsent: any = await invokeEndpoint.handler( - { method: "POST", path: "/api/v1/projects/{project_id}/folders", - path_params: { project_id: 1 }, body: { name: "New" } }, {} as any, - ); - expect(JSON.parse(noConsent.content[0].text).error).toMatch(/ask the user to confirm/); - - // A typo must surface as a parameter error, NOT as "go ask a human" about a call that - // was never going to run. - const typo: any = await invokeEndpoint.handler( - { method: "POST", path: "/api/v1/projects/{project_id}/folders", - path_params: { project_id: 1 }, body: { nmae: "New" } }, {} as any, - ); - expect(JSON.parse(typo.content[0].text).error).toMatch(/unknown body: nmae/); - expect(fetchSpy).not.toHaveBeenCalled(); - }); -}); diff --git a/tests/tools/capabilityRegistryRegion.test.ts b/tests/tools/capabilityRegistryRegion.test.ts deleted file mode 100644 index 5087e8f..0000000 --- a/tests/tools/capabilityRegistryRegion.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { fileURLToPath } from "node:url"; - -// The package discovers which region an account's Test Management lives on. Hardcoding the -// default host would fail every EU and IN account on every call, so this asserts the -// registry defers to that resolver rather than to a constant of its own. -vi.mock("../../src/lib/tm-base-url.js", () => ({ - getTMBaseURL: vi.fn(async () => "https://test-management-eu.browserstack.com"), -})); - -const FIXTURE = fileURLToPath(new URL("../fixtures/registry-index.json", import.meta.url)); -const CONFIG = { - "browserstack-username": "ing_Xx", - "browserstack-access-key": "SECRET", -} as any; - -describe("base URL resolution", () => { - beforeEach(() => { - process.env.CAPABILITY_REGISTRY_INDEX = FIXTURE; - delete process.env.CAPABILITY_REGISTRY_BASE_URL_TM; - vi.resetModules(); - }); - - afterEach(() => { - delete process.env.CAPABILITY_REGISTRY_INDEX; - delete process.env.CAPABILITY_REGISTRY_BASE_URL_TM; - vi.unstubAllGlobals(); - }); - - it("sends the request to the region the account actually lives on", async () => { - const calls: string[] = []; - vi.stubGlobal("fetch", async (url: string) => { - calls.push(String(url)); - return { - status: 200, - headers: { get: () => "application/json" }, - json: async () => ({ projects: [{ id: 1, name: "P" }], info: { count: 1 } }), - }; - }); - - const { BrowserStackMcpServer } = await import("../../src/server-factory.js"); - const server = new BrowserStackMcpServer(CONFIG); - const result: any = await (server.getTools().invokeEndpoint as any).handler( - { method: "GET", path: "/api/v1/projects/basic" }, {} as any, - ); - - expect(JSON.parse(result.content[0].text).ok).toBe(true); - expect(calls[0].startsWith("https://test-management-eu.browserstack.com/")).toBe(true); - }); - - it("an explicit override still wins, which is how a non-prod environment is reached", async () => { - process.env.CAPABILITY_REGISTRY_BASE_URL_TM = "https://tm-preprod.example"; - const calls: string[] = []; - vi.stubGlobal("fetch", async (url: string) => { - calls.push(String(url)); - return { - status: 200, - headers: { get: () => "application/json" }, - json: async () => ({ projects: [], info: { count: 0 } }), - }; - }); - - const { BrowserStackMcpServer } = await import("../../src/server-factory.js"); - const server = new BrowserStackMcpServer(CONFIG); - await (server.getTools().invokeEndpoint as any).handler( - { method: "GET", path: "/api/v1/projects/basic" }, {} as any, - ); - expect(calls[0].startsWith("https://tm-preprod.example/")).toBe(true); - }); - - it("refuses a product whose host is unknown instead of guessing one", async () => { - // A guessed host fails as a DNS error or a 404 that reads like the caller's problem. - const { resolveBaseUrl } = await import("../../src/tools/capability-registry/config.js"); - await expect(resolveBaseUrl("a11y", CONFIG)).rejects.toThrow(/no host is configured/); - }); -}); - -describe("harness declares, config overrides — the same precedence Atlas uses", () => { - const HARNESS_HOST = "https://tm.harness-declared.example"; - - it("uses the harness-declared host when config says nothing", async () => { - delete process.env.CAPABILITY_REGISTRY_BASE_URL_TM; - const { resolveBaseUrl } = await import("../../src/tools/capability-registry/config.js"); - expect(await resolveBaseUrl("tm", CONFIG, `${HARNESS_HOST}/`)).toBe(HARNESS_HOST); - }); - - it("lets config override the harness", async () => { - process.env.CAPABILITY_REGISTRY_BASE_URL_TM = "https://tm-preprod.example"; - const { resolveBaseUrl } = await import("../../src/tools/capability-registry/config.js"); - expect(await resolveBaseUrl("tm", CONFIG, HARNESS_HOST)).toBe("https://tm-preprod.example"); - }); - - it("falls through to region discovery when the harness declares nothing", async () => { - // Which is tm's actual situation: its product.yaml leaves the host to config precisely - // so that per-account region sharding is honoured. - delete process.env.CAPABILITY_REGISTRY_BASE_URL_TM; - const { resolveBaseUrl } = await import("../../src/tools/capability-registry/config.js"); - expect(await resolveBaseUrl("tm", CONFIG, undefined)) - .toBe("https://test-management-eu.browserstack.com"); - }); -}); - -describe("multiple environments, the way Atlas defines them", () => { - const HARNESS_HOST = "https://tm.harness-declared.example"; - - async function resolver() { - return (await import("../../src/tools/capability-registry/config.js")).resolveBaseUrl; - } - - afterEach(() => { - delete process.env.CAPABILITY_REGISTRY_ENV; - delete process.env.CAPABILITY_REGISTRY_BASE_URLS; - delete process.env.CAPABILITY_REGISTRY_BASE_URL_TM_PREPROD; - }); - - it("picks this environment's host from a per-env variable", async () => { - process.env.CAPABILITY_REGISTRY_ENV = "preprod"; - process.env.CAPABILITY_REGISTRY_BASE_URL_TM_PREPROD = "https://tm-preprod.example/"; - expect(await (await resolver())("tm", CONFIG, HARNESS_HOST)) - .toBe("https://tm-preprod.example"); - }); - - it("picks it from one map, the analogue of harness.extra_environments", async () => { - process.env.CAPABILITY_REGISTRY_ENV = "preprod"; - process.env.CAPABILITY_REGISTRY_BASE_URLS = JSON.stringify({ - tm: { preprod: "https://tm-preprod.example", prod: "https://tm.example" }, - }); - expect(await (await resolver())("tm", CONFIG, HARNESS_HOST)) - .toBe("https://tm-preprod.example"); - }); - - it("lets the environment-agnostic override win, as Atlas's session seam does", async () => { - process.env.CAPABILITY_REGISTRY_ENV = "preprod"; - process.env.CAPABILITY_REGISTRY_BASE_URL_TM = "https://seam.example"; - process.env.CAPABILITY_REGISTRY_BASE_URL_TM_PREPROD = "https://tm-preprod.example"; - expect(await (await resolver())("tm", CONFIG, HARNESS_HOST)).toBe("https://seam.example"); - delete process.env.CAPABILITY_REGISTRY_BASE_URL_TM; - }); - - it("refuses when an environment is named but has no host, rather than falling back", async () => { - // Falling through to the harness default here would point a preprod deployment at - // production, silently — the worst available outcome. - process.env.CAPABILITY_REGISTRY_ENV = "preprod"; - await expect((await resolver())("tm", CONFIG, HARNESS_HOST)) - .rejects.toThrow(/environment 'preprod' has no host/); - }); - - it("refuses a malformed map instead of reading it as 'no override'", async () => { - process.env.CAPABILITY_REGISTRY_ENV = "preprod"; - process.env.CAPABILITY_REGISTRY_BASE_URLS = "{not json"; - await expect((await resolver())("tm", CONFIG, HARNESS_HOST)) - .rejects.toThrow(/not valid JSON/); - }); - - it("ignores the environment entirely when none is selected", async () => { - process.env.CAPABILITY_REGISTRY_BASE_URLS = JSON.stringify({ - tm: { preprod: "https://tm-preprod.example" }, - }); - expect(await (await resolver())("tm", CONFIG, HARNESS_HOST)).toBe(HARNESS_HOST); - }); -}); From d6f528151417dad1897f5b981b372dc4bb130450 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Wed, 26 Aug 2026 16:46:22 +0530 Subject: [PATCH 22/31] Say in the description that this is the fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tool sat among forty-four hand-written ones without saying when it should be preferred to any of them, and a description is the only thing steering that choice — the client picks from these words alone, before a single call is made. So it now leads with the case it is actually for: no other tool here fits, or the ones tried did not get there. The ordering is the point rather than the wording. When to reach for it comes first, because that is the question being asked at selection time; what it does and what consent looks like follow, because those only matter once it has been chosen. It also says plainly to prefer a specific tool when one fits, since a dedicated endpoint is faster and more predictable than an agent working out its own API calls. The assertion on it is anchored to the start of the string, so a later edit cannot quietly demote the fallback framing into a trailing sentence nobody reads. --- src/tools/ask-browserstack/register.ts | 27 ++++++++++++++++++++------ tests/tools/askBrowserstackE2E.test.ts | 17 ++++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/src/tools/ask-browserstack/register.ts b/src/tools/ask-browserstack/register.ts index cb5d12d..28745ba 100644 --- a/src/tools/ask-browserstack/register.ts +++ b/src/tools/ask-browserstack/register.ts @@ -96,13 +96,28 @@ export interface AskDeps { startListener?: typeof startCallbackListener; } +/** + * THE FALLBACK POSITIONING IN THE FIRST SENTENCE IS LOAD-BEARING. + * + * This server registers 45 tools, most of them hand-written for one endpoint each. Those are + * faster, cheaper and more predictable than handing a task to an agent that has to work out + * its own API calls, so they should win whenever one of them actually fits. What this tool + * covers is the gap: a task nothing here has a tool for, or one where the specific tools were + * tried and did not get there. + * + * A description is the ONLY thing steering that choice — the client picks a tool from these + * words alone, before any call is made — so the ordering is deliberate: when to reach for it + * first, what it does second, and the consent behaviour last. + */ const DESCRIPTION = - "Ask a question or request a change in plain language about a BrowserStack product. " + - "BrowserStack's agent decides which calls to make and returns its answer plus the steps " + - "it took. Anything that would change data pauses and asks you to confirm it first, in " + - "your own client; deletes are refused outright. If your client cannot show you a prompt, " + - "the run is read-only and everything it wanted to change comes back in `needs_approval` " + - "instead. One task per call."; + "Use this when no other BrowserStack tool here fits the task, or when the ones you tried " + + "did not get you there. Prefer a specific tool whenever one fits: it is faster and more " + + "predictable than handing the job to an agent. " + + "Otherwise, describe what you want in plain language and BrowserStack's agent decides " + + "which calls to make, then returns its answer plus the steps it took. Anything that would " + + "change data pauses and asks you to confirm it first, in your own client; deletes are " + + "refused outright. If your client cannot show you a prompt, the run is read-only and " + + "everything it wanted to change comes back in `needs_approval` instead. One task per call."; /** * `isError` marks a call that FAILED, not one that was refused. diff --git a/tests/tools/askBrowserstackE2E.test.ts b/tests/tools/askBrowserstackE2E.test.ts index 211a61f..dbcacfe 100644 --- a/tests/tools/askBrowserstackE2E.test.ts +++ b/tests/tools/askBrowserstackE2E.test.ts @@ -158,6 +158,23 @@ describe("askBrowserstackAI, end to end through the server factory", () => { }); describe("the client CAN elicit", () => { + it("advertises itself as the fallback, so specific tools win when one fits", async () => { + // The description is the ONLY thing steering tool choice — the client picks from these + // words before any call happens — so the fallback framing has to be in it, and near + // the front where it is read. + const server = await buildServer(); + const description = (server.getTools().askBrowserstackAI as any).description as string; + + expect(description).toMatch(/^Use this when no other BrowserStack tool here fits/); + expect(description).toMatch(/or when the ones you tried did not get you there/); + expect(description).toMatch(/Prefer a specific tool whenever one fits/); + // ...and it still says what it does and what consent looks like. + expect(description).toMatch(/plain language/); + expect(description).toMatch(/asks you to confirm/); + expect(description).toMatch(/deletes are refused outright/); + expect(description).toMatch(/One task per call/); + }); + it("relays an ask, approves it, and forwards the caller's own credentials", async () => { const server = await buildServer(); const elicit = fakeClient(server.getInstance(), { elicitation: {} }, [ From 792461ff10a7d335738e7eb3764dcf63165e3d65 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Wed, 26 Aug 2026 20:22:43 +0530 Subject: [PATCH 23/31] =?UTF-8?q?feat(ask):=20A1=20stream=20transport=20?= =?UTF-8?q?=E2=80=94=20nothing=20dials=20in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CONTRACT v2. `callback.ts` binds 127.0.0.1: and hands Atlas the URL, which works only when Atlas shares that loopback. For a real user it cannot work at all: this server runs on their machine, and a laptop behind NAT is not addressable from a pod in BrowserStack's cluster. No configuration creates that route. Every successful relay run to date used a locally-run Atlas — the shape v1 was written for, not the shape a user is in. A1 makes both connections outbound from here: the ask arrives on the open `POST /agent` SSE response, and each decision goes back as a fresh short `POST /agent/{run_id}/permission`. Adds `stream.ts` — `AgentStreamTransport` and `DecisionTransport` seams, `splitFrames`, `parseFrame`, and fetch-based implementations of both. This file deliberately decides NOTHING. Whether a human approved, how an elicitation outcome maps to allow/deny, what the result looks like — all of that stays in `relay.ts`, untouched and shared with the callback transport. Keeping the judgement out of the transport is why swapping A2 for A1 does not put the fail-closed behaviour at risk. `callback.ts` is untouched too: it stays the working path for a local Atlas until the Atlas half ships (v2 §7.5), because deleting it now would leave the feature with no working transport at all. Chunk boundaries get most of the test attention on purpose. A frame split mid-JSON, two frames in one read, a trailing frame with no blank line, an unparseable frame, the heartbeat — a dropped frame is an ask that never reaches the human, i.e. a write that silently never gets approved and never says why, and it is the failure a hand-rolled SSE parser actually produces. An unparseable frame is dropped rather than guessed at: the worst case is that Atlas's gate denies on its own expiry, never that something is approved. A non-stream response throws instead of iterating empty, because an empty iteration is indistinguishable from "the run finished and said nothing". A decision that never left reports 0 rather than a refusal, so a lost request is never reported as a human saying no. Timeouts follow v2 §4: the whole-run guard is 1800s because the stream now spans a run that may hold several 300s approvals in series, so the old 330s outer rung meant nothing. The per-ask rung (270s elicitation inside Atlas's 300s gate) is unchanged and still enforced where it belongs. 16 new tests, 480 passing overall. Typecheck and lint clean. --- src/tools/ask-browserstack/stream.ts | 200 +++++++++++++++++++ tests/tools/askBrowserstackStream.test.ts | 228 ++++++++++++++++++++++ 2 files changed, 428 insertions(+) create mode 100644 src/tools/ask-browserstack/stream.ts create mode 100644 tests/tools/askBrowserstackStream.test.ts diff --git a/src/tools/ask-browserstack/stream.ts b/src/tools/ask-browserstack/stream.ts new file mode 100644 index 0000000..6bafe9b --- /dev/null +++ b/src/tools/ask-browserstack/stream.ts @@ -0,0 +1,200 @@ +/** + * CONTRACT v2 (A1) — read the ask off the response, send the decision separately. + * + * WHY THIS REPLACES THE CALLBACK. `callback.ts` binds `127.0.0.1:` and hands + * Atlas the URL. That works only when Atlas is on the same loopback. For a real user it + * cannot work at all: this server runs on their machine, and a laptop behind NAT is not + * addressable from a pod in BrowserStack's cluster. There is no route, and no + * configuration creates one. Every successful relay run to date used a locally-run + * Atlas — the shape the v1 contract was written for, and not the shape a user is in. + * + * A1 inverts it. Both connections are outbound from here: + * + * 1. `POST /agent` — the response is an SSE stream carrying `run`, then any + * `permission` asks, then exactly one `result`. + * 2. `POST /agent/{run_id}/permission` — a fresh short request per decision. + * + * So NAT, firewalls and loopback stop mattering, because nothing ever dials in. + * + * WHAT THIS FILE DELIBERATELY DOES NOT DO: decide anything. Whether a human approved, + * how an elicitation outcome maps to allow/deny, what the result looks like — all of + * that stays in `relay.ts`, untouched, and is shared with the callback transport. This + * is a pipe. Keeping the judgement out of the transport is why swapping A2 for A1 does + * not risk the fail-closed behaviour. + */ + +import logger from "../../logger.js"; +import { AskError } from "./config.js"; +import type { AgentRequest } from "./types.js"; + +/** One SSE frame, already parsed. `data` is whatever JSON the frame carried. */ +export interface StreamEvent { + event: string; + data: unknown; +} + +/** + * CONTRACT v2 §4 — the whole-run guard, so a runaway run cannot hold a tool call open + * forever. Deliberately generous: it is a backstop against a hung server, not a budget + * for a human's attention, and the thing that actually bounds one approval is Atlas's + * 300s gate. + */ +export const WHOLE_RUN_TIMEOUT_MS = 1_800_000; + +export const EVENT_RUN = "run"; +export const EVENT_PERMISSION = "permission"; +export const EVENT_RESULT = "result"; + +/** + * The A1 transport seam, mirroring `AgentTransport` but yielding many events instead of + * returning one body. Injectable for the same reason that one is: the tests must be able + * to drive a whole approval round trip without a socket. + */ +export type AgentStreamTransport = ( + url: string, + headers: Record, + body: AgentRequest, +) => AsyncIterable; + +/** Posts one decision. Separate seam because it is a separate connection. */ +export type DecisionTransport = ( + url: string, + headers: Record, + body: { perm_id: string; decision: string; reason: string }, +) => Promise; + +/** + * Split a buffer into complete SSE frames, returning the leftover. + * + * Exported for its own tests because chunk boundaries are where SSE parsers break: a + * frame can arrive split across two reads, two frames can arrive in one read, and a + * `data:` line can contain anything except a newline. Getting this wrong shows up as an + * ask that is silently dropped — a write that never gets approved and never explains + * why — so it is tested directly rather than only through the happy path. + */ +export function splitFrames(buffer: string): { frames: string[]; rest: string } { + const frames: string[] = []; + let rest = buffer; + for (;;) { + const idx = rest.indexOf("\n\n"); + if (idx === -1) break; + frames.push(rest.slice(0, idx)); + rest = rest.slice(idx + 2); + } + return { frames, rest }; +} + +/** + * Parse one frame. Returns null for anything that is not an event we can use — + * including the heartbeat, which is a bare `:` comment and is SUPPOSED to be ignored + * here: its only job is to be a read on the socket so the ingress does not time the + * connection out while a human is thinking. + */ +export function parseFrame(frame: string): StreamEvent | null { + let event = ""; + const dataLines: string[] = []; + for (const line of frame.split("\n")) { + if (line.startsWith(":")) continue; // comment / heartbeat + if (line.startsWith("event:")) event = line.slice(6).trim(); + else if (line.startsWith("data:")) dataLines.push(line.slice(5).trim()); + } + if (!event) return null; + if (dataLines.length === 0) return { event, data: null }; + try { + return { event, data: JSON.parse(dataLines.join("\n")) }; + } catch { + // A frame we cannot read is not a frame we may guess at. Dropping it is safe + // because the only consequence is that an ask goes unanswered and the gate denies + // on its own expiry — never that something is approved. + logger.warn("askBrowserstackAI: unparseable stream frame, ignoring"); + return null; + } +} + +/** + * A fetch-based streaming transport. + * + * `timeoutMs` bounds the WHOLE run, not one request — CONTRACT v2 §4 replaced the old + * 330s outer rung because under A1 the stream lives for the run and may contain several + * 300s approvals in series. The per-ask rung (270s elicitation inside Atlas's 300s gate) + * is unchanged and still enforced where it belongs. + */ +export function fetchAgentStreamTransport( + timeoutMs = WHOLE_RUN_TIMEOUT_MS, +): AgentStreamTransport { + return function stream(url, headers, body) { + return { + async *[Symbol.asyncIterator]() { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { + method: "POST", + headers: { ...headers, Accept: "text/event-stream" }, + body: JSON.stringify(body), + // A redirect from an authenticated API is usually a login bounce, and + // following it turns a clear 401 into a 200 carrying an HTML page. + redirect: "manual", + signal: controller.signal, + }); + + if (!response.ok || !response.body) { + // Not a stream. Surface it as a first-class failure rather than an empty + // iteration, which the caller could not tell apart from "the run finished + // and said nothing". + throw new AskError( + `BrowserStack AI refused the stream (HTTP ${response.status}).`, + ); + } + + const decoder = new TextDecoder(); + let buffer = ""; + for await (const chunk of response.body) { + buffer += decoder.decode(chunk as Uint8Array, { stream: true }); + const { frames, rest } = splitFrames(buffer); + buffer = rest; + for (const frame of frames) { + const parsed = parseFrame(frame); + if (parsed) yield parsed; + } + } + // A trailing frame with no terminating blank line still counts. + const tail = parseFrame(buffer); + if (tail) yield tail; + } finally { + clearTimeout(timer); + } + }, + }; + }; +} + +/** The decision POST. 30s, because it is an ordinary short request. */ +export function fetchDecisionTransport(timeoutMs = 30_000): DecisionTransport { + return async (url, headers, body) => { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), timeoutMs); + try { + const response = await fetch(url, { + method: "POST", + headers, + body: JSON.stringify(body), + redirect: "manual", + signal: controller.signal, + }); + return response.status; + } catch { + // The gate on the far side is still waiting and will deny on its own expiry, so + // a lost decision is safe — it is never an approval. 0 says "never delivered" so + // the caller can say that rather than implying a human refused. + return 0; + } finally { + clearTimeout(timer); + } + }; +} + +/** `POST /agent/{run_id}/permission`, built from the base URL the tool already resolved. */ +export function decisionUrl(agentUrl: string, runId: string): string { + return `${agentUrl.replace(/\/+$/, "")}/${encodeURIComponent(runId)}/permission`; +} diff --git a/tests/tools/askBrowserstackStream.test.ts b/tests/tools/askBrowserstackStream.test.ts new file mode 100644 index 0000000..3d21936 --- /dev/null +++ b/tests/tools/askBrowserstackStream.test.ts @@ -0,0 +1,228 @@ +/** + * CONTRACT v2 (A1) — the stream transport. + * + * These test the PIPE, not the judgement. Whether a human approved, how an elicitation + * outcome maps to allow/deny, what the result looks like: all of that is `relay.ts`, + * which A1 does not touch and which `askBrowserstack.test.ts` already covers. Mixing + * the two here would imply the transport can influence a decision, which is the exact + * property the design prevents. + * + * Chunk boundaries get most of the attention on purpose. A dropped frame is an ask that + * never reaches the human — a write that silently never gets approved and never says + * why — and it is the failure a hand-rolled SSE parser actually produces. + */ + +import { describe, expect, it, vi } from "vitest"; + +import { + EVENT_PERMISSION, + EVENT_RESULT, + EVENT_RUN, + WHOLE_RUN_TIMEOUT_MS, + decisionUrl, + fetchAgentStreamTransport, + fetchDecisionTransport, + parseFrame, + splitFrames, +} from "../../src/tools/ask-browserstack/stream.js"; + +describe("splitFrames", () => { + it("returns complete frames and keeps the remainder", () => { + const { frames, rest } = splitFrames("a\n\nb\n\npartial"); + expect(frames).toEqual(["a", "b"]); + expect(rest).toBe("partial"); + }); + + it("holds a frame that has not terminated yet", () => { + // The single most likely real failure: an ask arrives split across two reads. If + // this returned it early the JSON would be truncated and the ask lost. + const { frames, rest } = splitFrames("event: permission\ndata: {\"perm"); + expect(frames).toEqual([]); + expect(rest).toBe('event: permission\ndata: {"perm'); + }); + + it("handles several frames arriving in one read", () => { + const { frames, rest } = splitFrames("one\n\ntwo\n\nthree\n\n"); + expect(frames).toEqual(["one", "two", "three"]); + expect(rest).toBe(""); + }); +}); + +describe("parseFrame", () => { + it("parses an event and its JSON payload", () => { + expect(parseFrame('event: run\ndata: {"run_id":"run-abc"}')).toEqual({ + event: "run", + data: { run_id: "run-abc" }, + }); + }); + + it("ignores the heartbeat", () => { + // A bare comment. Its only job is to be a read on the socket so the ingress does + // not close the connection while a human is thinking, so it must not surface as + // an event the caller has to know about. + expect(parseFrame(": keepalive")).toBeNull(); + }); + + it("ignores a frame with no event name", () => { + expect(parseFrame('data: {"stray":true}')).toBeNull(); + }); + + it("drops a frame whose JSON will not parse rather than guessing", () => { + // Safe to drop: the only consequence is that an ask goes unanswered and Atlas's + // gate denies on its own expiry. Never that something is approved. + expect(parseFrame("event: permission\ndata: {not json")).toBeNull(); + }); + + it("keeps a description containing JSON-escaped punctuation", () => { + const frame = + 'event: permission\ndata: {"perm_id":"p","description":"Mark run \\"1043\\" done"}'; + expect((parseFrame(frame)?.data as { description: string }).description).toBe( + 'Mark run "1043" done', + ); + }); +}); + +/** A Response whose body streams the given chunks, as Node's fetch would. */ +function streamingResponse(chunks: string[], status = 200): Response { + const encoder = new TextEncoder(); + const body = new ReadableStream({ + start(controller) { + for (const c of chunks) controller.enqueue(encoder.encode(c)); + controller.close(); + }, + }); + return new Response(body, { + status, + headers: { "content-type": "text/event-stream" }, + }); +} + +describe("fetchAgentStreamTransport", () => { + it("yields run, permission and result in order across chunk boundaries", async () => { + // The frames are deliberately split mid-JSON and mid-frame, which is what a real + // socket does and what a naive parser gets wrong. + const fetchMock = vi.fn().mockResolvedValue( + streamingResponse([ + 'event: run\ndata: {"run_id":"run-1"}\n', + '\nevent: permission\ndata: {"perm_id":"perm-1","product":"tm",', + '"mode":"ask-once","description":"Mark run 1043 complete"}\n\n', + ": keepalive\n\n", + 'event: result\ndata: {"ok":true,"status":"ok"}\n\n', + ]), + ); + vi.stubGlobal("fetch", fetchMock); + + const seen: Array<{ event: string; data: unknown }> = []; + for await (const ev of fetchAgentStreamTransport()( + "https://atlas.test/agent", + { Authorization: "Bearer t" }, + { task: "t", product: "tm" } as never, + )) { + seen.push(ev); + } + + expect(seen.map((e) => e.event)).toEqual([ + EVENT_RUN, + EVENT_PERMISSION, + EVENT_RESULT, + ]); + expect((seen[1].data as { description: string }).description).toBe( + "Mark run 1043 complete", + ); + // Asks for a stream explicitly, and keeps the caller's auth header. + const init = fetchMock.mock.calls[0][1]; + expect(init.headers.Accept).toBe("text/event-stream"); + expect(init.headers.Authorization).toBe("Bearer t"); + expect(init.redirect).toBe("manual"); + vi.unstubAllGlobals(); + }); + + it("throws rather than iterating empty when the response is not a stream", async () => { + // An empty iteration is indistinguishable from "the run finished and said + // nothing", so a refusal has to be loud. + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response("nope", { status: 403 })), + ); + const iterate = async () => { + for await (const _ of fetchAgentStreamTransport()( + "https://atlas.test/agent", + {}, + {} as never, + )) { + void _; + } + }; + await expect(iterate()).rejects.toThrow(/HTTP 403/); + vi.unstubAllGlobals(); + }); + + it("yields a trailing frame that never got its blank line", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue( + streamingResponse(['event: result\ndata: {"ok":true}']), + ), + ); + const seen = []; + for await (const ev of fetchAgentStreamTransport()( + "https://atlas.test/agent", + {}, + {} as never, + )) { + seen.push(ev); + } + expect(seen).toHaveLength(1); + expect(seen[0].event).toBe(EVENT_RESULT); + vi.unstubAllGlobals(); + }); + + it("bounds the whole run, not one request", () => { + // v2 §4: the old 330s outer rung meant nothing once the stream spans a run that + // may hold several 300s approvals in series. + expect(WHOLE_RUN_TIMEOUT_MS).toBe(1_800_000); + }); +}); + +describe("fetchDecisionTransport", () => { + it("returns the status so the caller can tell 204 from 409", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue(new Response(null, { status: 204 })), + ); + const status = await fetchDecisionTransport()( + "https://atlas.test/agent/run-1/permission", + {}, + { perm_id: "perm-1", decision: "allow", reason: "" }, + ); + expect(status).toBe(204); + vi.unstubAllGlobals(); + }); + + it("reports 0 when the decision never left, rather than implying a refusal", async () => { + // The gate on the far side is still waiting and denies on its own expiry, so a + // lost decision is safe. But it must not be reported as "the human said no". + vi.stubGlobal("fetch", vi.fn().mockRejectedValue(new Error("socket closed"))); + const status = await fetchDecisionTransport()( + "https://atlas.test/agent/run-1/permission", + {}, + { perm_id: "perm-1", decision: "allow", reason: "" }, + ); + expect(status).toBe(0); + vi.unstubAllGlobals(); + }); +}); + +describe("decisionUrl", () => { + it("builds the v2 §3 path from the agent url", () => { + expect(decisionUrl("https://atlas.test/agent", "run-abc")).toBe( + "https://atlas.test/agent/run-abc/permission", + ); + }); + + it("tolerates a trailing slash and escapes the id", () => { + expect(decisionUrl("https://atlas.test/agent/", "run-a/b")).toBe( + "https://atlas.test/agent/run-a%2Fb/permission", + ); + }); +}); From fb17e70e54b7f1020675dddfe158a33ae668900e Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Wed, 26 Aug 2026 20:38:23 +0530 Subject: [PATCH 24/31] =?UTF-8?q?feat(ask):=20wire=20the=20tool=20onto=20A?= =?UTF-8?q?1=20=E2=80=94=20no=20port,=20no=20callback,=20no=20NAT=20proble?= =?UTF-8?q?m?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `askBrowserstackAI` now drives CONTRACT v2. The relay block it sends is `{ mode: "stream" }` — no URL and no per-run bearer, because nothing dials in. The ask arrives on the open `POST /agent` response and each decision goes back on its own `POST /agent/{run_id}/permission`. `relay.ts` is untouched, as v2 §7.3 predicted: `relayOneAsk`, `decide`, `buildResult`, the trail precedence and every `permission_relay` reason are shared with the callback transport. The new `runStreamed` is a loop that reads events, elicits, posts the decision and hands the result to `buildResult`. It decides nothing. TWO REAL BUGS the existing tests caught, both in that loop: 1. **The HTTP status was being dropped.** I built the AgentResponse with a hardcoded 200, which lost the 401/403 that `relay.ts` needs to tell a rejected credential from an account without the feature from an ordinary failure — three different sentences for the user, all collapsing into a generic error. The transport now carries the status on a result synthesised from a non-stream reply; a real stream is 200 by definition. 2. **A failed elicitation abandoned the run.** `relayOneAsk` RETHROWS on an unexpected failure, which under A2 was load-bearing: the throw made the inbound callback answer 500 and Atlas read that as a deny. Under A1 there is no inbound request to fail, so the throw escaped and left Atlas waiting out its full 300s gate — a client hiccup becoming a five-minute stall. Now caught and converted into the explicit deny the throw used to imply. Also: a fetch failure maps to "BrowserStack AI could not be reached" rather than leaking "connection reset"/"fetch failed", matching the request/response transport — the upstream detail names our plumbing, not anything the reader can act on. An Atlas that predates v2 answers `POST /agent` with plain JSON. The stream transport yields that as a single `result`, so the tool degrades to a correct read-only answer with `permission_relay.reason: "disabled"`. No version flag and no negotiation, which is what makes it safe to ship this before every Atlas serves A1. Test migration: repointing the one `atlas()` stub from A2 to A1 fixed 12 of the 30 failures on its own — which is the evidence that these assertions were about `relay.ts` rather than the transport. The stub now streams `run`, holds each `permission` frame open until the tool answers it on the decision endpoint, then sends `result`. Four tests had premises A1 makes impossible and were rewritten to assert the same invariant in the new shape rather than deleted: * the stray-process-with-the-wrong-bearer hazard is gone (no port, no bearer), so that test now covers what remains: a refused decision (409/404) must not be re-sent, because a retry could land an approval on a step the run has moved past. * listener teardown is gone (nothing is bound), so that test now pins that a transport failure still surfaces as a clean result rather than an escaping throw. * D4's loopback probe becomes "Atlas denies without us prompting" — same invariant: the two trails stay separate and ours being empty is the only record that no human was asked. * N1's 502-carrying-a-result cannot occur under A1 (an ask needs an open 200 stream), so the failure now arrives in the `result` event of a stream that did prompt and was approved. Same invariant: a run that asked and got a yes must not report "nothing was asked". 480 tests passing, typecheck and lint clean. --- src/tools/ask-browserstack/register.ts | 190 +++++++++++--- src/tools/ask-browserstack/stream.ts | 58 ++++- src/tools/ask-browserstack/types.ts | 17 +- tests/tools/askBrowserstackE2E.test.ts | 332 +++++++++++++++---------- 4 files changed, 421 insertions(+), 176 deletions(-) diff --git a/src/tools/ask-browserstack/register.ts b/src/tools/ask-browserstack/register.ts index 28745ba..9cb6dda 100644 --- a/src/tools/ask-browserstack/register.ts +++ b/src/tools/ask-browserstack/register.ts @@ -45,16 +45,20 @@ import { isEnabled, } from "./config.js"; import { fetchTokenTransport, mintCentralToken } from "./central-oauth.js"; +import { AgentTransport, Credentials, agentHeaders } from "./egress.js"; +// A2's transport lives on per CONTRACT v2 §7.5 and nothing here calls it: until every +// Atlas serves A1, deleting `callback.ts` would leave a co-located caller with no working +// transport at all. Only the TYPE is needed, to keep `AskDeps.startListener` declared. +import { startCallbackListener } from "./callback.js"; import { - AgentTransport, - Credentials, - agentHeaders, - fetchAgentTransport, -} from "./egress.js"; -import { - CallbackListener, - startCallbackListener, -} from "./callback.js"; + EVENT_PERMISSION, + EVENT_RESULT, + EVENT_RUN, + decisionUrl, + fetchAgentStreamTransport, + fetchDecisionTransport, +} from "./stream.js"; +import type { AgentStreamTransport, DecisionTransport } from "./stream.js"; import { buildResult, decide, @@ -91,9 +95,16 @@ export interface AskDeps { * as the human rather than as a shared service account. */ credentialsFor: () => Credentials; + /** A2's seam. Unused by the tool today; kept while callback.ts is (v2 §7.5). */ transport?: AgentTransport; - /** The seam the tests bind a fake listener to. */ + /** A2's seam. Same. */ startListener?: typeof startCallbackListener; + /** + * A1's seams. Injectable for the same reason A2's were: a test must be able to drive + * a whole approval round trip — ask, elicit, decide, result — without a socket. + */ + streamTransport?: AgentStreamTransport; + decisionTransport?: DecisionTransport; } /** @@ -239,6 +250,116 @@ async function relayOneAsk( * Redis, the pattern `POST /api/agent-callback/{correlation_id}` already uses — plus a callback * URL that addresses a specific replica. A config flag will not do it. */ +/** + * CONTRACT v2 (A1) — drive one run over the stream. + * + * The loop is the whole orchestration: read events, elicit on each `permission`, POST + * the decision, hand the `result` to `buildResult`. It decides nothing itself — + * `relayOneAsk` still owns the elicitation and the allow/deny mapping, and it is the + * SAME function the callback transport used. That is deliberate: the transport changed, + * the judgement did not. + * + * Reading pauses while a human is being prompted, which is correct rather than merely + * tolerable: Atlas is blocked on that decision and will emit nothing but heartbeats + * until it arrives, and heartbeats are dropped by the parser. + * + * A run that ends with no `result` is an error, not an empty success. A stream that + * simply stops is indistinguishable from a network drop, and reporting it as a finished + * run with no answer would be the transport quietly speaking for the agent. + */ +async function runStreamed( + server: McpServer, + streamTransport: AgentStreamTransport, + decisionTransport: DecisionTransport, + url: string, + headers: Record, + body: AgentRequest, + approvals: ApprovalRecord[], + mode: RelayMode, + product: string, +): Promise { + let runId = ""; + let result: unknown; + let sawResult = false; + // 200 unless the reply was not a stream at all, in which case the transport carries + // the real status — `relay.ts` needs it to tell 401 from 403 from a plain failure. + let resultStatus = 200; + + for await (const event of streamTransport(url, headers, body)) { + if (event.event === EVENT_RUN) { + runId = String((event.data as { run_id?: string })?.run_id || ""); + continue; + } + if (event.event === EVENT_RESULT) { + result = event.data; + sawResult = true; + if (typeof event.status === "number") resultStatus = event.status; + continue; + } + if (event.event !== EVENT_PERMISSION) continue; + + const ask = event.data as PermissionAsk; + if (!runId) { + // Atlas emits `run` before any ask precisely so this cannot happen. If it does, + // there is nowhere to send a decision — so do not prompt a human for an answer + // that could never be delivered. + logger.error( + "askBrowserstackAI: permission ask arrived before run_id; cannot answer", + ); + continue; + } + + // `relayOneAsk` RETHROWS on an unexpected elicitation failure. Under A2 that was + // load-bearing: the throw made the inbound callback answer 500, which Atlas's + // fail-closed rule read as a deny. Under A1 there is no inbound request to fail, so + // letting it escape would abandon the run and leave Atlas waiting out its full 300s + // gate — turning a client hiccup into a five-minute stall. So it is caught here and + // converted into the explicit deny the throw used to imply. `relayOneAsk` has + // already recorded the approvals entry, so only the wire decision is missing. + let decision: PermissionDecision; + try { + decision = await relayOneAsk(server, ask, approvals); + } catch (error) { + logger.warn( + "askBrowserstackAI: elicitation failed, denying explicitly: %s", + error instanceof Error ? error.message : String(error), + ); + decision = { perm_id: ask.perm_id, decision: "deny", reason: "error" }; + } + const status = await decisionTransport( + decisionUrl(url, runId), + headers, + { + perm_id: decision.perm_id, + decision: decision.decision, + reason: decision.reason || "", + }, + ); + if (status !== 204) { + // Never fatal, and never re-sent. Atlas's gate is still waiting and denies on its + // own expiry, so a lost decision is safe — it can only cost an approval, never + // grant one. Retrying risks the opposite: a duplicate that 409s, or worse, an + // approval applied to a step the run has already moved past. + logger.warn( + "askBrowserstackAI: decision for %s was not accepted (HTTP %s)", + decision.perm_id, + status, + ); + } + } + + if (!sawResult) { + return errorResult( + "BrowserStack AI ended the run without a result. Nothing was changed " + + "beyond any step you already approved.", + approvals, + ); + } + // Shaped as an `AgentResponse` so `buildResult` — shared with the callback transport + // and unchanged — sees exactly what it always saw. + return buildResult({ status: resultStatus, body: result }, approvals, mode, product); +} + export function relayMode(server: McpServer): RelayMode { if (appConfig.REMOTE_MCP) return "remote_mode"; return server.server.getClientCapabilities()?.elicitation @@ -251,8 +372,13 @@ export function addAskBrowserstackAITool( deps: AskDeps, config?: BrowserStackConfig, ): Record { - const transport = deps.transport || fetchAgentTransport(); - const startListener = deps.startListener || startCallbackListener; + // A1 (CONTRACT v2) is the path. `transport`/`startListener` remain wired for the + // callback transport, which stays on disk per v2 §7.5 — until every Atlas serves A1, + // deleting it would leave nothing that works for a co-located caller. Nothing calls + // it today; the stream degrades to a read-only JSON answer against an older Atlas, + // so no version flag is needed to be safe. + const streamTransport = deps.streamTransport || fetchAgentStreamTransport(); + const decisionTransport = deps.decisionTransport || fetchDecisionTransport(); const tools: Record = {}; /** Instrumentation in the house style, and never fatal to the call it wraps. */ @@ -292,7 +418,6 @@ export function addAskBrowserstackAITool( // Negotiated before anything else so the failure paths below report the mode they // would have run in. const mode = relayMode(server); - let listener: CallbackListener | undefined; try { const url = deps.agentUrl(); @@ -311,16 +436,12 @@ export function addAskBrowserstackAITool( const username = (deps.credentialsFor().username || "").trim(); if (username) body.user_id = username; - // NOT started at all in remote mode — see `relayMode`. Never bound, rather than - // bound and left to fail on a callback that cannot arrive. + // A1: asking for a stream costs nothing to set up — no port, no listener, no + // per-run bearer, because nothing dials in. Which is the whole point: the + // callback this replaces could never reach a laptop behind NAT, so the feature + // was read-only for every real user regardless of what was configured. if (mode === "offered") { - listener = await startListener((ask) => - relayOneAsk(server, ask, approvals), - ); - body.permission_relay = { - callback_url: listener.url, - token: listener.token, - }; + body.permission_relay = { mode: "stream" }; } else { // Omitted ENTIRELY, not sent empty: its absence is what selects Atlas's // read-only HeadlessGate. @@ -330,11 +451,14 @@ export function addAskBrowserstackAITool( ); } + // `product` reaches the result so an entitlement refusal can name it: the flags + // are per product, and a bare "not enabled" sends the user to their admin + // asking about the wrong thing. return toResult( - // `product` reaches the result so an entitlement refusal can name it: the flags - // are per product, and a bare "not enabled" sends the user to their admin asking - // about the wrong thing. - buildResult(await transport(url, headers, body), approvals, mode, product), + await runStreamed( + server, streamTransport, decisionTransport, + url, headers, body, approvals, mode, product, + ), ); } catch (error) { const message = @@ -345,18 +469,10 @@ export function addAskBrowserstackAITool( // No `canElicit` argument: the request never left this process, so whether the // client could have been prompted is not what the reader needs to know. return toResult(errorResult(message, approvals)); - } finally { - // Torn down here so it cannot leak across calls or survive an error, and awaited - // so the port is released before the tool result is handed back. - if (listener) { - await listener.close().catch((error) => { - logger.warn( - "askBrowserstackAI: permission callback listener did not close cleanly: %s", - error instanceof Error ? error.message : String(error), - ); - }); - } } + // No teardown: A1 opens no port and binds nothing, so there is nothing that can + // leak across calls or survive an error. The stream is closed by its own + // iteration ending, and Atlas drops the run when the response completes. }, ); diff --git a/src/tools/ask-browserstack/stream.ts b/src/tools/ask-browserstack/stream.ts index 6bafe9b..d578041 100644 --- a/src/tools/ask-browserstack/stream.ts +++ b/src/tools/ask-browserstack/stream.ts @@ -31,6 +31,16 @@ import type { AgentRequest } from "./types.js"; export interface StreamEvent { event: string; data: unknown; + /** + * The HTTP status, carried ONLY on a `result` synthesised from a non-stream reply. + * + * Load-bearing, and it was a bug to omit it. `relay.ts` reads the status to tell a + * rejected credential (401) from an account without the feature (403) from an + * ordinary failure, and those produce three different sentences for the user. A + * result event that dropped the status made every one of them read as a generic + * error. On a real SSE stream the status is 200 by definition, so this is absent. + */ + status?: number; } /** @@ -128,20 +138,44 @@ export function fetchAgentStreamTransport( const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { - const response = await fetch(url, { - method: "POST", - headers: { ...headers, Accept: "text/event-stream" }, - body: JSON.stringify(body), - // A redirect from an authenticated API is usually a login bounce, and - // following it turns a clear 401 into a 200 carrying an HTML page. - redirect: "manual", - signal: controller.signal, - }); + let response: Response; + try { + response = await fetch(url, { + method: "POST", + headers: { ...headers, Accept: "text/event-stream" }, + body: JSON.stringify(body), + // A redirect from an authenticated API is usually a login bounce, and + // following it turns a clear 401 into a 200 carrying an HTML page. + redirect: "manual", + signal: controller.signal, + }); + } catch { + // The same sentence the request/response transport gives, and for the same + // reason: the upstream detail ("connection reset", "fetch failed") names our + // plumbing rather than anything the reader can act on, and letting it through + // once already produced a result that read like the user had done something + // wrong. What they need to know is that BrowserStack was not reachable. + throw new AskError("BrowserStack AI could not be reached"); + } + + const contentType = response.headers.get("content-type") || ""; + + // GRACEFUL DEGRADE, and the reason A1 is safe to ship before Atlas has it + // everywhere: an Atlas that does not know `mode: "stream"` answers with an + // ordinary JSON body (a read-only run, `permission_relay.reason: "disabled"`). + // Yielding it as a single `result` means the caller needs no version + // negotiation and no flag — it gets a correct read-only answer instead of a + // parse failure against a body that was never SSE. + if (contentType.includes("json")) { + const parsed = await response.json().catch(() => null); + yield { event: EVENT_RESULT, data: parsed, status: response.status }; + return; + } if (!response.ok || !response.body) { - // Not a stream. Surface it as a first-class failure rather than an empty - // iteration, which the caller could not tell apart from "the run finished - // and said nothing". + // Neither a stream nor a JSON result. Surface it as a first-class failure + // rather than an empty iteration, which the caller could not tell apart + // from "the run finished and said nothing". throw new AskError( `BrowserStack AI refused the stream (HTTP ${response.status}).`, ); diff --git a/src/tools/ask-browserstack/types.ts b/src/tools/ask-browserstack/types.ts index 84d6a69..d273799 100644 --- a/src/tools/ask-browserstack/types.ts +++ b/src/tools/ask-browserstack/types.ts @@ -58,9 +58,22 @@ export interface PermissionDecision { } /** CONTRACT §1 — the one new optional field on `POST /agent`. */ +/** + * The `permission_relay` block. Two shapes, one per transport: + * + * * `{ mode: "stream" }` — A1 / CONTRACT v2. Nothing to address and nothing to + * authenticate inbound, because nothing dials in. This is what ships. + * * `{ callback_url, token }` — A2 / CONTRACT v1. Retained for a co-located caller + * until every Atlas serves A1 (v2 §7.5); unused by the tool today. + * + * Both optional rather than a union so a caller cannot half-fill either one: Atlas + * checks for `mode` first, so a block carrying both is read as a stream and never + * silently routed onto the transport that cannot reach the caller. + */ export interface PermissionRelay { - callback_url: string; - token: string; + mode?: string; + callback_url?: string; + token?: string; } /** diff --git a/tests/tools/askBrowserstackE2E.test.ts b/tests/tools/askBrowserstackE2E.test.ts index dbcacfe..6e75d9b 100644 --- a/tests/tools/askBrowserstackE2E.test.ts +++ b/tests/tools/askBrowserstackE2E.test.ts @@ -29,62 +29,123 @@ interface AtlasCall { * Plays Atlas: receives POST /agent, then calls the MCP server back over REAL loopback * HTTP for each ask before answering. Every hop except Atlas's own logic is genuine. */ +/** + * A fake Atlas speaking CONTRACT v2 (A1). + * + * The shape change from A2 is the whole point and it is visible right here: this stub no + * longer dials back into the tool. It streams `run`, then a `permission` frame per + * scripted ask, then one `result` — and each ask is held open until the tool answers it + * on a SEPARATE `POST /agent/{run_id}/permission`, which lands on this same stub. + * + * Every assertion these tests make is about `relay.ts` and `buildResult`, which A1 does + * not touch. Repointing this one function is therefore the whole migration: if the + * behaviour those tests pin were transport-dependent, that would be the bug. + */ function atlas(options: { asks?: { perm_id: string; description: string }[]; - token?: (real: string) => string; payload?: (decisions: any[]) => unknown; throws?: boolean; authStatus?: number; authError?: string; + /** Answer the decision POST with something other than 204. */ + decisionStatus?: number; + /** Reply to `POST /agent` with plain JSON, as an Atlas that predates A1 does. */ + json?: boolean; }) { const calls: AtlasCall[] = []; const decisions: any[] = []; const mints: Record[] = []; + const RUN_ID = "run-" + "a".repeat(32); + const encoder = new TextEncoder(); + let answered: ((body: unknown) => void) | null = null; + + const frame = (event: string, data: unknown) => + encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); + + const jsonResponse = (status: number, payload: unknown) => ({ + ok: status >= 200 && status < 300, + status, + headers: { get: (k: string) => (k === "content-type" ? "application/json" : "") }, + json: async () => payload, + }); const stub = async (url: string, init: any) => { if (String(url) === AUTH_URL) { mints.push(Object.fromEntries(new URLSearchParams(init.body))); - return { - status: options.authStatus ?? 200, - headers: { get: () => "application/json" }, - json: async () => (options.authStatus && options.authStatus !== 200 + return jsonResponse( + options.authStatus ?? 200, + options.authStatus && options.authStatus !== 200 ? { error: options.authError ?? "invalid_client", error_description: "scope ai_agent_notify only valid for: user_management, access_key SECRET", } - : { access_token: MINTED, expires_in: 3600, token_type: "Bearer" }), - }; + : { access_token: MINTED, expires_in: 3600, token_type: "Bearer" }, + ); } + + // The decision endpoint. Recording it and releasing the stream is what makes this + // an A1 round trip rather than a scripted playback. + if (/\/agent\/[^/]+\/permission$/.test(String(url))) { + const body = JSON.parse(init.body); + const status = options.decisionStatus ?? 204; + decisions.push({ status, body }); + answered?.(body); + answered = null; + return { ok: status < 300, status, headers: { get: () => "" }, json: async () => null }; + } + const body = JSON.parse(init.body); calls.push({ url: String(url), headers: init.headers, body }); if (options.throws) throw new Error("connection reset"); - for (const ask of options.asks || []) { - const relay = body.permission_relay; - const response = await realFetch(relay.callback_url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${options.token ? options.token(relay.token) : relay.token}`, - }, - body: JSON.stringify({ ...ask, product: body.product, mode: "ask-always" }), - }); - decisions.push({ status: response.status, body: await response.json() }); + const payloadFor = () => + options.payload + ? options.payload(decisions) + : { status: "ok", answer: "done", needs_approval: [] }; + + // No relay asked for, or an Atlas that does not know A1: a plain JSON body. The + // tool's stream transport degrades to a single `result`, which is exactly how it + // stays safe against a deployment that has not shipped v2 yet. + if (!body.permission_relay || options.json) { + return jsonResponse(200, payloadFor()); } - const payload = options.payload - ? options.payload(decisions) - : { status: "ok", answer: "done", needs_approval: [] }; + const stream = new ReadableStream({ + async start(controller) { + controller.enqueue(frame("run", { run_id: RUN_ID })); + for (const ask of options.asks || []) { + const wait = new Promise((resolve) => { + answered = resolve; + }); + controller.enqueue( + frame("permission", { + ...ask, + product: body.product, + mode: "ask-always", + }), + ); + // Held open deliberately: Atlas's gate blocks here, and a stub that raced + // ahead would test a sequence the real server cannot produce. + await wait; + } + controller.enqueue(frame("result", payloadFor())); + controller.close(); + }, + }); return { + ok: true, status: 200, - headers: { get: () => "application/json" }, - json: async () => payload, + headers: { + get: (k: string) => (k === "content-type" ? "text/event-stream" : ""), + }, + body: stream, + json: async () => null, }; }; vi.stubGlobal("fetch", stub); - return { calls, decisions, mints }; + return { calls, decisions, mints, RUN_ID }; } /** @@ -202,13 +263,18 @@ describe("askBrowserstackAI, end to end through the server factory", () => { // The EXACT key set. /agent has no Api-Token path, and that header carries the user's // access key — this assertion is what stops it creeping back in. expect(Object.keys(stub.calls[0].headers).sort()) - .toEqual(["Authorization", "Content-Type", "request-source"]); + // `Accept: text/event-stream` is A1's: it asks for the stream explicitly. The + // point of pinning the exact set is unchanged — no access key, no cookie, no + // second credential may appear here. + .toEqual(["Accept", "Authorization", "Content-Type", "request-source"]); expect(stub.calls[0].body.task).toBe("make a folder"); expect(stub.calls[0].body.product).toBe("tm"); expect(stub.calls[0].body.user_id).toBe("ing_Xx"); - expect(stub.calls[0].body.permission_relay.callback_url) - .toMatch(/^http:\/\/127\.0\.0\.1:\d+\/atlas-permission$/); - expect(stub.calls[0].body.permission_relay.token).toHaveLength(64); + // A1: the block names the transport and carries NOTHING else. No URL, because + // there is nothing to dial; no per-run bearer, because there is no inbound + // connection to authenticate. That absence is the fix — the URL this used to + // carry was a loopback address a pod could never reach. + expect(stub.calls[0].body.permission_relay).toEqual({ mode: "stream" }); // 2. the prompt: framed with the product, Atlas's description verbatim, boolean confirm expect(elicit).toHaveBeenCalledTimes(1); @@ -229,7 +295,8 @@ describe("askBrowserstackAI, end to end through the server factory", () => { // 3. the answer on the wire, echoing Atlas's own id expect(stub.decisions[0]) - .toEqual({ status: 200, body: { perm_id: PERM_A, decision: "allow", reason: "" } }); + // 204: v2 §3.3, the decision endpoint has nothing to return. + .toEqual({ status: 204, body: { perm_id: PERM_A, decision: "allow", reason: "" } }); // 4. the result expect(payload.ok).toBe(true); @@ -376,7 +443,7 @@ describe("askBrowserstackAI, end to end through the server factory", () => { const { payload } = await call(server.getTools()); expect(stub.decisions[0]) - .toEqual({ status: 200, body: { perm_id: PERM_A, decision: "deny", reason: "timeout" } }); + .toEqual({ status: 204, body: { perm_id: PERM_A, decision: "deny", reason: "timeout" } }); expect(payload.approvals[0].reason).toBe("timeout"); }); @@ -386,27 +453,43 @@ describe("askBrowserstackAI, end to end through the server factory", () => { const stub = atlas({ asks: [{ perm_id: PERM_A, description: "Archive the plan." }] }); const { payload } = await call(server.getTools()); - // Atlas's fail-closed rule reads any non-200 as a deny. - expect(stub.decisions[0].status).toBe(500); + // A1 inverts WHERE the break shows up. Under A2 the callback answered 500 and + // Atlas read that as a deny. Now the elicitation itself failed on our side, so we + // are the ones who must send an explicit deny — silence would leave Atlas waiting + // out its 300s gate. The invariant is the same and it is the one that matters: a + // broken channel never becomes an approval. + expect(stub.decisions[0].body).toEqual({ + perm_id: PERM_A, decision: "deny", reason: "error", + }); expect(payload.approvals[0]).toEqual({ description: "Archive the plan.", decision: "deny", reason: "error", outcome: "refused: the approval channel broke before any answer arrived", }); }); - it("ignores a callback that cannot present the run's token, and elicits nothing", async () => { + it("does not retry or fail the run when a decision is refused", async () => { + // The A1 replacement for A2's "a stray local process cannot present the run's + // token". That hazard is GONE: nothing dials in, so there is no inbound + // connection to authenticate and no per-run bearer to steal. Atlas authorises the + // decision instead, on the attested JWT plus an unguessable run_id (v2 §3.1). + // + // What remains on this side is the opposite risk: a decision Atlas refuses (409 + // already-decided, 404 stale) must not be re-sent. A retry could land an approval + // on a step the run has already moved past. Losing it is safe — Atlas's gate + // denies on its own expiry — so we log and carry on. const server = await buildServer(); const elicit = fakeClient(server.getInstance(), { elicitation: {} }, [ - { action: "accept", content: { confirm: true } }, + { action: "accept" }, ]); const stub = atlas({ asks: [{ perm_id: PERM_A, description: "Create folder." }], - token: () => "a-stray-local-process", + decisionStatus: 409, }); - await call(server.getTools()); - expect(stub.decisions[0].status).toBe(401); - expect(elicit).not.toHaveBeenCalled(); + const { payload } = await call(server.getTools()); + expect(elicit).toHaveBeenCalledTimes(1); + expect(stub.decisions).toHaveLength(1); // sent once, never re-sent + expect(payload.status).toBe("ok"); // and the run still completed }); it("says some steps applied before it stopped", async () => { @@ -513,14 +596,19 @@ describe("askBrowserstackAI, end to end through the server factory", () => { expect(result.isError).toBe(true); }); - it("keeps the two trails apart when a probe is answered with no prompt (D4)", async () => { + it("keeps the two trails apart when Atlas denies without us prompting (D4)", async () => { + // A2's version of this was a stray local process probing the loopback port with the + // wrong bearer: 401, zero prompts, and Atlas recording a denial we never saw. That + // hazard is GONE under A1 — there is no port to probe and no bearer to get wrong. + // + // The INVARIANT it protected is not gone, and is what this now covers: Atlas's + // trail and ours are separate records, and ours being empty is the only evidence + // that no human was ever prompted. Atlas can refuse a step on its own — an expired + // gate, a policy refusal — and when it does, the two trails must not be merged. const server = await buildServer(); const elicit = fakeClient(server.getInstance(), { elicitation: {} }, []); - // A callback arriving with the wrong bearer: 401, zero prompts. Atlas sees the STEP - // refused and records a denial; we saw nobody, and recorded nothing. const stub = atlas({ - asks: [{ perm_id: PERM_A, description: "Create folder." }], - token: () => "a-stray-local-process", + // No asks: Atlas refused the step without ever putting one on the stream. payload: () => ({ status: "blocked", answer: "", needs_approval: ["Create folder."], approvals: [ @@ -533,13 +621,12 @@ describe("askBrowserstackAI, end to end through the server factory", () => { const { payload } = await call(server.getTools()); - expect(stub.decisions[0].status).toBe(401); - expect(elicit).not.toHaveBeenCalled(); + expect(stub.decisions).toEqual([]); // we answered nothing + expect(elicit).not.toHaveBeenCalled(); // and nobody was prompted // Atlas's is authoritative... expect(payload.approvals_source).toBe("atlas"); expect(payload.approvals[0].decision).toBe("deny"); - // ...and ours is empty, which is the ONLY record that no prompt ever appeared. That - // difference is what a probe of the loopback port looks like, so it must survive. + // ...and ours is empty, which is the ONLY record that no prompt ever appeared. expect(payload.elicitations).toEqual([]); expect(payload.applied_before_stop).toBe(false); }); @@ -567,59 +654,41 @@ describe("askBrowserstackAI, end to end through the server factory", () => { expect(payload.applied_before_stop).toBeNull(); }); - it("does not claim nobody was asked when a 502 carries a real result (N1)", async () => { + it("does not claim nobody was asked when the run fails after an approval (N1)", async () => { + // A2's version used a 502 whose BODY carried a real result. Under A1 that shape + // cannot occur: an ask requires an open 200 stream, so a 502 can never have + // carried one. The failure now arrives where it belongs — in the `result` event of + // a stream that did prompt and was approved. + // + // The invariant is unchanged and is the one N1 fixed: a run that asked and got a + // yes must NOT be reported as "nothing was asked". Reading the status alone got + // this wrong; the body is the signal. const server = await buildServer(); const elicit = fakeClient(server.getInstance(), { elicitation: {} }, [ - { action: "accept", content: { confirm: true } }, + { action: "accept" }, ]); - // Atlas answers 502 when a delegation RAN and a step then failed. One prompt was - // shown and approved; the write did not land. - const realFetchLocal = realFetch; - vi.stubGlobal("fetch", withAuth(async (_url: string, init: any) => { - const body = JSON.parse(init.body); - await realFetchLocal(body.permission_relay.callback_url, { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${body.permission_relay.token}`, - }, - body: JSON.stringify({ - perm_id: PERM_A, product: "tm", mode: "ask-always", + const stub = atlas({ + asks: [{ perm_id: PERM_A, description: 'Creating the "Regression" folder.' }], + payload: () => ({ + ok: false, status: "error", answer: "The folder was not created.", steps: [], + approvals: [{ description: 'Creating the "Regression" folder.', - }), - }); - return { - status: 502, - headers: { get: () => "application/json" }, - json: async () => ({ - ok: false, status: "error", answer: "The folder was not created.", steps: [], - approvals: [{ - description: 'Creating the "Regression" folder.', - decision: "allow", reason: "", applied: false, - }], - applied_before_stop: false, - permission_relay: { used: true, reason: "" }, - }), - }; - })); + decision: "allow", reason: "", applied: false, + }], + applied_before_stop: false, + permission_relay: { used: true, reason: "" }, + }), + }); const { payload } = await call(server.getTools()); - // A prompt WAS shown and approved — the client recorded it. expect(elicit).toHaveBeenCalledTimes(1); - expect(payload.elicitations[0].decision).toBe("allow"); - // ...so nothing in the payload may say otherwise. - expect(payload.permission_relay).toEqual({ - used: true, reason: "", - detail: expect.stringContaining("asked before each change"), - }); - expect(payload.permission_relay.detail).not.toMatch(/NOTHING WAS ASKED/); - expect(payload.approvals[0]).toMatchObject({ - decision: "allow", applied: false, - outcome: expect.stringContaining("APPROVED, BUT THE CHANGE DID NOT GO THROUGH"), - }); - expect(payload.approvals_source).toBe("atlas"); - expect(payload.status).toBe("error"); + expect(stub.decisions[0].body.decision).toBe("allow"); + // The run failed, but a human WAS asked and did approve — so the relay verdict + // must not read as "not_reached", and the trail must survive. + expect(payload.permission_relay.used).toBe(true); + expect(payload.permission_relay.reason).toBe(""); + expect(payload.approvals[0].decision).toBe("allow"); expect(payload.applied_before_stop).toBe(false); }); @@ -699,16 +768,20 @@ describe("askBrowserstackAI, end to end through the server factory", () => { // so this branch is no longer reachable from the factory — but the wire rule still // holds and must stay covered. const mcp = new McpServer({ name: "t", version: "0" }); - const transport = vi.fn(async () => ({ status: 200, body: { status: "ok", answer: "" } })); + const streamed = vi.fn(() => ({ + async *[Symbol.asyncIterator]() { + yield { event: "result", data: { status: "ok", answer: "" } }; + }, + })); const tools = addAskBrowserstackAITool(mcp, { agentUrl: () => "https://atlas.example/agent", mintToken: async () => MINTED, credentialsFor: () => ({ username: "", accessKey: "" }), - transport: transport as never, + streamTransport: streamed as never, }); await call(tools); - const body = (transport.mock.calls[0] as any)[2]; + const body = (streamed.mock.calls[0] as never as Record[])[2]; expect("user_id" in body).toBe(false); expect(Object.keys(body).sort()).toEqual(["product", "task"]); }); @@ -1080,10 +1153,11 @@ describe("askBrowserstackAI, end to end through the server factory", () => { "@modelcontextprotocol/sdk/server/mcp.js" ); - const startListener = vi.fn(async () => ({ - url: "http://127.0.0.1:1/atlas-permission", token: "t", close: async () => {}, + const streamed = vi.fn(() => ({ + async *[Symbol.asyncIterator]() { + yield { event: "result", data: { status: "ok", answer: "" } }; + }, })); - const transport = vi.fn(async () => ({ status: 200, body: { status: "ok", answer: "" } })); const remote = new RemoteMcpServer({ name: "t", version: "0" }); vi.spyOn(remote.server, "getClientCapabilities") @@ -1092,13 +1166,15 @@ describe("askBrowserstackAI, end to end through the server factory", () => { agentUrl: () => "https://atlas.example/agent", mintToken: async () => MINTED, credentialsFor: () => ({ username: "ing_Xx", accessKey: "SECRET" }), - transport: transport as never, - startListener: startListener as never, + streamTransport: streamed as never, }); const { payload } = await call(tools); - expect(startListener).not.toHaveBeenCalled(); - expect("permission_relay" in (transport.mock.calls[0] as any)[2]).toBe(false); + // A1 binds nothing anywhere, so "never bound a port" is no longer the property to + // assert — it is now true by construction. What still matters, and is what this + // guarded all along, is that the hosted deployment OFFERS no relay: the ask + // channel it would get cannot survive being spread across replicas (v2 §5). + expect("permission_relay" in (streamed.mock.calls[0] as never as unknown[])[2]!).toBe(false); expect(payload.permission_relay.reason).toBe("remote_mode"); }); @@ -1114,10 +1190,11 @@ describe("askBrowserstackAI, end to end through the server factory", () => { "@modelcontextprotocol/sdk/server/mcp.js" ); - const startListener = vi.fn(async () => ({ - url: "http://127.0.0.1:1/atlas-permission", token: "t", close: async () => {}, + const streamed = vi.fn(() => ({ + async *[Symbol.asyncIterator]() { + yield { event: "result", data: { status: "ok", answer: "" } }; + }, })); - const transport = vi.fn(async () => ({ status: 200, body: { status: "ok", answer: "" } })); const stdio = new StdioMcpServer({ name: "t", version: "0" }); vi.spyOn(stdio.server, "getClientCapabilities") @@ -1126,16 +1203,18 @@ describe("askBrowserstackAI, end to end through the server factory", () => { agentUrl: () => "https://atlas.example/agent", mintToken: async () => MINTED, credentialsFor: () => ({ username: "ing_Xx", accessKey: "SECRET" }), - transport: transport as never, - startListener: startListener as never, + streamTransport: streamed as never, }); await call(tools); - expect(startListener).toHaveBeenCalledTimes(1); - expect((transport.mock.calls[0] as any)[2].permission_relay).toBeDefined(); + // Without this the assertion above would pass just as happily if the relay were + // never offered to anyone. + expect(streamed).toHaveBeenCalledTimes(1); + expect((streamed.mock.calls[0] as never as unknown[])[2]) + .toMatchObject({ permission_relay: { mode: "stream" } }); }); - it("stdio does not regress: the listener still binds and the block is still sent", async () => { + it("stdio does not regress: the relay is still offered and still works", async () => { // REMOTE_MCP unset — byte-identical to every other test in this file. const server = await buildServer(); fakeClient(server.getInstance(), { elicitation: {} }, [ @@ -1144,9 +1223,7 @@ describe("askBrowserstackAI, end to end through the server factory", () => { const stub = atlas({ asks: [{ perm_id: PERM_A, description: "Create folder." }] }); const { payload } = await call(server.getTools()); - expect(stub.calls[0].body.permission_relay.callback_url) - .toMatch(/^http:\/\/127\.0\.0\.1:\d+\/atlas-permission$/); - expect(stub.calls[0].body.permission_relay.token).toHaveLength(64); + expect(stub.calls[0].body.permission_relay).toEqual({ mode: "stream" }); expect(payload.permission_relay.used).toBe(true); expect(payload.permission_relay.reason).toBe(""); }); @@ -1217,39 +1294,44 @@ describe("askBrowserstackAI, against the injected seam", () => { // It is not sent on this route, so its absence must not refuse the call the way the // product-API path would. const mcp = new McpServer({ name: "t", version: "0" }); - const transport = vi.fn(async () => ({ status: 200, body: { status: "ok", answer: "" } })); + const streamed = vi.fn(() => ({ + async *[Symbol.asyncIterator]() { + yield { event: "result", data: { status: "ok", answer: "" } }; + }, + })); const tools = addAskBrowserstackAITool(mcp, { agentUrl: () => "https://atlas.example/agent", mintToken: async () => MINTED, credentialsFor: () => ({ username: "ing_Xx", accessKey: "" }), - transport: transport as never, - startListener: (async () => ({ url: "x", token: "y", close: async () => {} })) as never, + streamTransport: streamed as never, }); const { payload } = await call(tools); expect(payload.ok).toBe(true); - expect(transport).toHaveBeenCalledTimes(1); - expect((transport.mock.calls[0] as any)[2].user_id).toBe("ing_Xx"); + expect(streamed).toHaveBeenCalledTimes(1); + expect((streamed.mock.calls[0] as never as unknown[])[2]) + .toMatchObject({ user_id: "ing_Xx" }); }); - it("closes the listener even when the transport throws", async () => { + it("reports a transport failure with nothing left to clean up", async () => { + // Under A2 this test existed because a thrown transport could strand a bound port, + // and the assertion was that the listener still closed. A1 binds NOTHING — no port, + // no listener, no per-run bearer — so the leak this guarded against cannot happen. + // What is left worth pinning is that the failure still surfaces as a clean result + // rather than an exception escaping the tool. const mcp = new McpServer({ name: "t", version: "0" }); vi.spyOn(mcp.server, "getClientCapabilities").mockReturnValue({ elicitation: {} } as never); - const close = vi.fn(async () => {}); const tools = addAskBrowserstackAITool(mcp, { agentUrl: () => "https://atlas.example/agent", mintToken: async () => MINTED, credentialsFor: () => ({ username: "u", accessKey: "k" }), - transport: (async () => { + streamTransport: (() => { throw new Error("boom"); }) as never, - startListener: (async () => ({ - url: "http://127.0.0.1:1/atlas-permission", token: "t", close, - })) as never, }); const { payload } = await call(tools); expect(payload.error).toBe("boom"); - expect(close).toHaveBeenCalledTimes(1); + expect(payload.ok).toBe(false); }); }); From 9f39eecdeed3b5068dedc341ad0e69fb4bf18203 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Wed, 26 Aug 2026 21:02:36 +0530 Subject: [PATCH 25/31] fix(ask): stop claiming a person answered when nobody was there MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Observed live against a real Atlas. A headless client returned `cancel`, and the one result carried both of these: permission_relay.detail: "This client can prompt you, so BrowserStack asked before each change and the answers are in `approvals`." approvals[0].outcome: "refused: nobody was there to be asked" Two sentences in the same payload contradicting each other, and the reader pays for it — exactly the confusion the `disabled` vs `no_human` sentences exist to prevent, one layer up. Cause: `relayVerdict` picks the sentence from the negotiated MODE and Atlas's advisory field. Neither can see what the elicitation actually returned, so a client that DECLARES elicitation capability but answers `cancel` gets a sentence claiming a human engaged. "The channel is usable" and "a person actually answered" are different facts, and only the second licenses the word "answers". Fixed in `buildResult`, the one place holding both the verdict and the trail: when the channel worked and every ask came back `cancelled`/`no_human`, the detail says nobody was present and names what to do about it. Guarded three ways so it cannot overreach — it does not fire when a person genuinely declined (`declined` is a human saying no, which needs a different sentence), nor on an empty trail (nothing was asked at all, already described correctly), nor when any ask was allowed. 484 tests, typecheck and lint clean. --- src/tools/ask-browserstack/relay.ts | 44 +++++++++++- tests/tools/askBrowserstackStream.test.ts | 88 +++++++++++++++++++++++ 2 files changed, 131 insertions(+), 1 deletion(-) diff --git a/src/tools/ask-browserstack/relay.ts b/src/tools/ask-browserstack/relay.ts index 559f98d..12d73b0 100644 --- a/src/tools/ask-browserstack/relay.ts +++ b/src/tools/ask-browserstack/relay.ts @@ -20,6 +20,22 @@ export const RELAY_ON_DETAIL = "This client can prompt you, so BrowserStack asked before each change and the answers " + "are in `approvals`."; +/** + * Used when the channel worked and every ask came back with nobody behind it. + * + * `RELAY_ON_DETAIL` cannot be used here and saying it was a bug, observed live: it claims + * "BrowserStack asked before each change and the answers are in `approvals`" while the + * trail says `refused: nobody was there to be asked`. Both sentences were in the same + * result, contradicting each other — the same class of confusion as `disabled` vs a human + * saying no, and it is the reader who pays for it. The channel being usable and a person + * actually answering are different facts, and only the second one licenses the word + * "answers". + */ +export const RELAY_ON_NO_ANSWER_DETAIL = + "BrowserStack asked before each change, but this client answered without a person " + + "present, so every change was refused. Nothing was modified. Approve from a client " + + "that can show you the prompt."; + /** * One sentence per way the relay can fail to run, because they call for different things * from the person reading them, and a caller who cannot tell them apart retries forever. @@ -342,6 +358,24 @@ export function parseAtlasApprovals( * An `allow` with no `applied` key is NOT rendered as a failure: nobody measured it, and * saying otherwise would invent the very fact this is meant to report. */ +/** + * Every ask was refused because no person was there — not because one said no. + * + * `cancelled` is what a client with nobody at the terminal returns (measured), and + * `no_human` is our own word for the same thing. An EMPTY trail is not this case: nothing + * was asked at all, which the existing sentences already describe correctly. + */ +export function nobodyAnswered(trail: ApprovalRecord[]): boolean { + return ( + trail.length > 0 && + trail.every( + (e) => + e.decision !== "allow" && + (e.reason === "cancelled" || e.reason === "no_human"), + ) + ); +} + export function approvalOutcome(entry: ApprovalRecord): string { if (entry.decision === "allow") { if (entry.applied === true) return "approved, and the change went through"; @@ -488,6 +522,14 @@ export function buildResult( const trail = atlasTrail ?? approvals; const status = deriveStatus(response, trail, needsApproval); const reachedAgent = !neverReachedAgent(response); + const relay = relayVerdict(payload, mode, reachedAgent, isNotEntitled(response)); + // Correct the sentence when the channel was fine but nobody was ever behind it. The + // verdict is computed from the MODE and Atlas's advisory field, neither of which can + // see what the elicitation actually returned — so only here, with the trail in hand, + // is "was a person really asked" knowable. + if (relay.used && relay.reason === "" && nobodyAnswered(trail)) { + relay.detail = RELAY_ON_NO_ANSWER_DETAIL; + } return { ok: status === "ok", @@ -499,7 +541,7 @@ export function buildResult( elicitations: withOutcomes(approvals), needs_approval: needsApproval, applied_before_stop: readAppliedBeforeStop(payload), - permission_relay: relayVerdict(payload, mode, reachedAgent, isNotEntitled(response)), + permission_relay: relay, atlas_response: response.body ?? null, ...atlasError(response, payload, product), }; diff --git a/tests/tools/askBrowserstackStream.test.ts b/tests/tools/askBrowserstackStream.test.ts index 3d21936..9d5b31d 100644 --- a/tests/tools/askBrowserstackStream.test.ts +++ b/tests/tools/askBrowserstackStream.test.ts @@ -226,3 +226,91 @@ describe("decisionUrl", () => { ); }); }); + +describe("the relay never claims a person answered when none did", () => { + it("says nobody was present, instead of 'the answers are in approvals'", async () => { + // Observed live against a real Atlas: a headless client returned `cancel`, and the + // result carried BOTH "BrowserStack asked before each change and the answers are in + // `approvals`" AND an approvals entry reading "refused: nobody was there to be + // asked". Two sentences in one payload contradicting each other. + const { buildResult, RELAY_ON_NO_ANSWER_DETAIL, nobodyAnswered } = await import( + "../../src/tools/ask-browserstack/relay.js" + ); + + expect( + nobodyAnswered([ + { description: "Create folder.", decision: "deny", reason: "cancelled" }, + ]), + ).toBe(true); + + const result = buildResult( + { + status: 200, + body: { + status: "blocked", + answer: null, + approvals: [ + { description: "Create folder.", decision: "deny", reason: "cancelled", + applied: false }, + ], + applied_before_stop: false, + permission_relay: { used: true, reason: "" }, + }, + }, + [{ description: "Create folder.", decision: "deny", reason: "cancelled" }], + "offered", + "tm", + ); + + expect(result.permission_relay.used).toBe(true); + expect(result.permission_relay.detail).toBe(RELAY_ON_NO_ANSWER_DETAIL); + // And the two halves of the payload now agree with each other. + expect(result.approvals[0].outcome).toMatch(/nobody was there/); + expect(result.permission_relay.detail).toMatch(/without a person/); + }); + + it("still credits a real human answer", async () => { + // The correction must not fire when somebody actually declined — "a person said no" + // and "no person was there" call for different things from the reader. + const { buildResult, RELAY_ON_DETAIL, nobodyAnswered } = await import( + "../../src/tools/ask-browserstack/relay.js" + ); + expect( + nobodyAnswered([ + { description: "x", decision: "deny", reason: "declined" }, + ]), + ).toBe(false); + + const result = buildResult( + { + status: 200, + body: { + status: "blocked", + approvals: [{ description: "x", decision: "deny", reason: "declined", + applied: false }], + permission_relay: { used: true, reason: "" }, + }, + }, + [{ description: "x", decision: "deny", reason: "declined" }], + "offered", + "tm", + ); + expect(result.permission_relay.detail).toBe(RELAY_ON_DETAIL); + }); + + it("does not fire on an empty trail", async () => { + // Nothing asked at all is a different state, already described correctly. + const { nobodyAnswered } = await import("../../src/tools/ask-browserstack/relay.js"); + expect(nobodyAnswered([])).toBe(false); + }); + + it("does not fire when one ask was allowed", async () => { + const { nobodyAnswered } = await import("../../src/tools/ask-browserstack/relay.js"); + expect( + nobodyAnswered([ + { description: "a", decision: "allow", reason: "" }, + { description: "b", decision: "deny", reason: "cancelled" }, + ]), + ).toBe(false); + }); +}); From ed04fa8ebf2c377e12c8457b2c1d89323d02660d Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Thu, 27 Aug 2026 00:08:11 +0530 Subject: [PATCH 26/31] Stop reporting a 5xx from auth as rejected credentials MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Observed live. Preprod auth was down, returned 503, and the tool answered "Your BrowserStack credentials were rejected … Check BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY" — sending someone to audit env vars that had worked minutes earlier. The reader's own guess ("is preprod down?") was right and the message argued against it. `mintOnce` had two branches: unreachable (status 0) and "not 200", and the second one read the body to tell a scope problem from a credential one. A 5xx has neither, so it fell through to the credential sentence. Now a `status >= 500` check sits between them, BEFORE the refusal branch, with its own detail that says the service is failing and states plainly that the credentials are not the problem. The status alone settles it: OAuth2 puts a bad client at 401/403 and a bad request at 400, so nothing in the 5xx range is ever a statement about the caller. The 401 path is asserted alongside it, because the split is only worth having if a genuine rejection still reads as one — the two need opposite actions from whoever reads them. Formatting churn in this file is `npm run build`'s own prettier step. Co-Authored-By: Claude Opus 5 (1M context) --- src/tools/ask-browserstack/central-oauth.ts | 45 ++++++++++++++++--- tests/tools/askBrowserstackStream.test.ts | 48 +++++++++++++++++++++ 2 files changed, 88 insertions(+), 5 deletions(-) diff --git a/src/tools/ask-browserstack/central-oauth.ts b/src/tools/ask-browserstack/central-oauth.ts index 9bcff22..f7aea87 100644 --- a/src/tools/ask-browserstack/central-oauth.ts +++ b/src/tools/ask-browserstack/central-oauth.ts @@ -88,8 +88,16 @@ export type TokenTransport = ( * code is ever looked at and only when it is one of these. Anything else is ignored entirely * and the classification falls back to the status. */ -const SCOPE_ERROR_CODES = ["invalid_scope", "unauthorized_client", "invalid_request"]; -const CREDENTIAL_ERROR_CODES = ["invalid_client", "invalid_grant", "access_denied"]; +const SCOPE_ERROR_CODES = [ + "invalid_scope", + "unauthorized_client", + "invalid_request", +]; +const CREDENTIAL_ERROR_CODES = [ + "invalid_client", + "invalid_grant", + "access_denied", +]; /** * Was this refusal about the SCOPE or about the CREDENTIAL? @@ -103,7 +111,9 @@ const CREDENTIAL_ERROR_CODES = ["invalid_client", "invalid_grant", "access_denie */ export function refusalIsAboutScope(status: number, body: unknown): boolean { const payload = - typeof body === "object" && body !== null ? (body as Record) : {}; + typeof body === "object" && body !== null + ? (body as Record) + : {}; const code = typeof payload.error === "string" ? payload.error : ""; if (SCOPE_ERROR_CODES.includes(code)) return true; if (CREDENTIAL_ERROR_CODES.includes(code)) return false; @@ -139,6 +149,23 @@ export const AUTH_UNREACHABLE_DETAIL = "was made, no prompt appeared and nothing was changed. This is a connectivity or " + "auth-server problem, not a problem with your credentials."; +/** + * A 5xx from auth: their service is down, not your password. + * + * Split out because routing 5xx to `AUTH_REJECTED_DETAIL` actively misdirects the reader, + * and did: a preprod outage returned 503 and the tool answered "Your BrowserStack + * credentials were rejected … Check BROWSERSTACK_USERNAME and BROWSERSTACK_ACCESS_KEY", + * sending someone to audit env vars that had worked minutes earlier. The status alone + * settles it — OAuth2 says a bad client is 401/403 and a bad request is 400, so nothing in + * the 5xx range is ever a statement about the caller. + */ +export const AUTH_SERVER_ERROR_DETAIL = (status: number): string => + `BrowserStack auth is unavailable (HTTP ${status}). YOUR CREDENTIALS ARE NOT THE ` + + `PROBLEM — a 5xx is the auth service failing, not a rejection, so there is nothing to ` + + `change on your side and nothing to retry differently. NOTHING REACHED THE AGENT — no ` + + `request was made, no prompt appeared and nothing was changed. Try again once ` + + `BrowserStack auth is back.`; + export const AUTH_UNUSABLE_DETAIL = (status: number): string => `BrowserStack auth answered HTTP ${status} without issuing a token. NOTHING REACHED THE ` + `AGENT — no request was made, no prompt appeared and nothing was changed.`; @@ -165,7 +192,9 @@ export function resetTokenCache(): void { * secret is not left sitting in a map key for the life of the process. */ function cacheKey(url: string, credentials: Credentials): string { - const digest = createHash("sha256").update(credentials.accessKey).digest("hex"); + const digest = createHash("sha256") + .update(credentials.accessKey) + .digest("hex"); return `${url} ${credentials.username} ${CENTRAL_SCOPE} ${digest}`; } @@ -224,6 +253,11 @@ async function mintOnce( const response = await transport(url, mintForm(credentials)); if (response.status === 0) throw new AskError(AUTH_UNREACHABLE_DETAIL); + // 5xx BEFORE the refusal branch: a server error is not a refusal, and reading it as one + // is worse than saying nothing — it names the caller's credentials as the fault. + if (response.status >= 500) { + throw new AskError(AUTH_SERVER_ERROR_DETAIL(response.status)); + } if (response.status !== 200) { // ONLY THE STATUS CROSSES. The body is read solely to tell a provisioning problem from a // credential one, and nothing out of it is ever put in the message — a non-200 body can @@ -247,7 +281,8 @@ async function mintOnce( // Trust the SERVER's lifetime over what we asked for — it clamps to its own maximum, and // caching for the requested hour when it granted less would hand out a dead token. const granted = Number(body.expires_in); - const seconds = Number.isFinite(granted) && granted > 0 ? granted : REQUESTED_EXPIRES_IN; + const seconds = + Number.isFinite(granted) && granted > 0 ? granted : REQUESTED_EXPIRES_IN; return { token, lifetimeMs: seconds * 1000 }; } diff --git a/tests/tools/askBrowserstackStream.test.ts b/tests/tools/askBrowserstackStream.test.ts index 9d5b31d..a1d36f0 100644 --- a/tests/tools/askBrowserstackStream.test.ts +++ b/tests/tools/askBrowserstackStream.test.ts @@ -314,3 +314,51 @@ describe("the relay never claims a person answered when none did", () => { ).toBe(false); }); }); + +describe("an auth outage is not a credential problem", () => { + it("a 5xx says the service is down, and never names your credentials", async () => { + // Observed live: preprod auth returned 503 during an outage and the tool answered + // "Your BrowserStack credentials were rejected … Check BROWSERSTACK_USERNAME and + // BROWSERSTACK_ACCESS_KEY" — sending someone to audit env vars that had worked + // minutes earlier. The reader asked "is preprod down?", which was right, and the + // message argued against it. + const { AUTH_SERVER_ERROR_DETAIL, mintCentralToken } = await import( + "../../src/tools/ask-browserstack/central-oauth.js" + ); + + for (const status of [500, 502, 503, 504]) { + const transport = vi.fn(async () => ({ status, body: null })); + await expect( + mintCentralToken( + "https://auth.example/token", + { username: "u", accessKey: "k" }, + transport as never, + ), + ).rejects.toThrow(/auth is unavailable/); + } + + const text = AUTH_SERVER_ERROR_DETAIL(503); + expect(text).toMatch(/CREDENTIALS ARE NOT THE PROBLEM/); + expect(text).not.toMatch(/BROWSERSTACK_USERNAME/); + expect(text).not.toMatch(/rejected/); + }); + + it("still blames the credentials on a 401", async () => { + // The split must not make a genuine rejection sound like an outage: those need + // opposite actions from the reader. + const { mintCentralToken } = await import( + "../../src/tools/ask-browserstack/central-oauth.js" + ); + const transport = vi.fn(async () => ({ + status: 401, + body: { error: "invalid_client" }, + })); + await expect( + mintCentralToken( + "https://auth.example/token", + { username: "u", accessKey: "k" }, + transport as never, + ), + ).rejects.toThrow(/credentials were rejected/); + }); +}); From c307c32e13bb40fb7da453a4b38d79aeadac914c Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Thu, 27 Aug 2026 00:08:11 +0530 Subject: [PATCH 27/31] Remove the A2 callback transport from the MCP half MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Atlas removed it, so nothing on this side has anything to talk to. A2 opened a loopback listener per tool call and handed Atlas its URL; that only ever worked for a caller on the same host as the Atlas process, which is not how anyone runs this — an MCP server runs on a laptop and Atlas runs in a cluster. A1 (the ask arrives on the open `POST /agent` stream, the decision goes back as a fresh POST) is the shipped path and is verified end to end against a live Atlas. Deleted: `callback.ts` in full — the listener, its bearer, its 401/400/404 handling — plus `AgentTransport`/`fetchAgentTransport` in `egress.ts` (the one-request-one-response shape has nothing left to describe now that `/agent` is read as a stream), and `AskDeps.transport`/`.startListener`. `PermissionRelay` narrows to `{ mode }`; `mode` stays optional because the field's ABSENCE is what selects Atlas's read-only gate and that has to remain expressible. `parseAsk` MOVED rather than went with it, into `stream.ts`, and is now actually wired in: `runStreamed` was doing `event.data as PermissionAsk` — a bare cast, no validation. So a frame with a blank description would have produced a prompt asking a human to approve nothing, and one with an id off Atlas's `perm-<32 hex>` shape would have produced a prompt whose answer could never be routed back. Every reason that function existed was a property of the ASK, not of the direction it arrived from. Two tests were A2's alone. The remote-mode pair keeps its meaning and loses its wording: "binds no port" is true by construction now, and what it always guarded — the hosted deployment offers no relay, because a stateless replica cannot raise a server-initiated elicitation (v2 §5) — is what it now says. "Tears the listener down once the call ends" is gone: its subject does not exist, and it had already gone vacuous, reading `permission_relay.callback_url` off a body that carries only `{mode}` and passing on the throw from probing `undefined`. `host_not_allowed` stays mapped in `relay.ts` but is rewritten: only an Atlas that predates A1 can send it, and such a deployment still deserves a sentence rather than a raw enum. 479 tests pass; build and lint clean. Co-Authored-By: Claude Opus 5 (1M context) --- src/tools/ask-browserstack/callback.ts | 210 ------------------------- src/tools/ask-browserstack/config.ts | 11 +- src/tools/ask-browserstack/egress.ts | 57 +------ src/tools/ask-browserstack/register.ts | 129 ++++++++------- src/tools/ask-browserstack/relay.ts | 65 +++++--- src/tools/ask-browserstack/stream.ts | 56 ++++++- src/tools/ask-browserstack/types.ts | 31 ++-- tests/tools/askBrowserstack.test.ts | 166 +++++-------------- tests/tools/askBrowserstackE2E.test.ts | 41 ++--- 9 files changed, 248 insertions(+), 518 deletions(-) delete mode 100644 src/tools/ask-browserstack/callback.ts diff --git a/src/tools/ask-browserstack/callback.ts b/src/tools/ask-browserstack/callback.ts deleted file mode 100644 index fc557b5..0000000 --- a/src/tools/ask-browserstack/callback.ts +++ /dev/null @@ -1,210 +0,0 @@ -/** - * The loopback listener Atlas calls back on (CONTRACT §1-2). - * - * Transport is A2: Atlas makes the OUTBOUND request and blocks on its response, which is - * what dissolves the affinity problem that dominates PLAN.md — the decision returns on the - * same connection to the same pod, so there is no Redis nudge and no single-replica limit. - * - * THE THREAT MODEL IS LOCAL. This binds a port on the developer's own machine, so every - * other process on that machine can reach it. A stray one must never be able to make a - * confirmation prompt appear, because a human trained to approve prompts is the exploit. - * Hence: a fresh 256-bit bearer per run, compared in constant time, checked BEFORE the body - * is even parsed, and 401 with no elicitation attempted on any mismatch. - * - * Everything ambiguous is a deny. A body we cannot parse, a `perm_id` that is not Atlas's - * shape, a blank description, a handler that throws — none of them produce an approval, and - * each answers in a way CONTRACT's fail-closed rule already maps to deny on Atlas's side. - */ - -import { randomBytes, timingSafeEqual } from "node:crypto"; -import { createServer, IncomingMessage, Server, ServerResponse } from "node:http"; -import { AddressInfo, Socket } from "node:net"; - -import logger from "../../logger.js"; -import { PermissionAsk, PermissionDecision } from "./types.js"; - -/** The path half of `callback_url`. The port half is whatever the OS hands us. */ -export const CALLBACK_PATH = "/atlas-permission"; - -/** Atlas's `f"perm-{uuid.uuid4().hex}"`, and nothing else. */ -export const PERM_ID_PATTERN = /^perm-[0-9a-f]{32}$/; - -/** An ask is four short fields. Anything larger is not one. */ -const MAX_BODY_BYTES = 64 * 1024; - -export type AskHandler = (ask: PermissionAsk) => Promise; - -export interface CallbackListener { - /** Derived from the port actually bound, never a hardcoded one. */ - url: string; - /** Minted for this run alone. */ - token: string; - close(): Promise; -} - -/** Constant-time, and length-safe: `timingSafeEqual` throws on a length mismatch. */ -function tokenMatches(presented: string, expected: string): boolean { - const a = Buffer.from(presented, "utf8"); - const b = Buffer.from(expected, "utf8"); - // The token's length is fixed and public, so leaking it costs nothing. - if (a.length !== b.length) return false; - return timingSafeEqual(a, b); -} - -function bearer(header: string | undefined): string { - if (!header) return ""; - const match = /^bearer[ \t]+(.+)$/i.exec(header.trim()); - return match ? match[1].trim() : ""; -} - -function respond(response: ServerResponse, status: number, payload: unknown): void { - const text = JSON.stringify(payload); - response.writeHead(status, { - "Content-Type": "application/json", - "Content-Length": Buffer.byteLength(text), - }); - response.end(text); -} - -async function readBody(request: IncomingMessage): Promise { - const chunks: Buffer[] = []; - let size = 0; - for await (const chunk of request) { - const buffer = chunk as Buffer; - size += buffer.length; - if (size > MAX_BODY_BYTES) throw new Error("body too large"); - chunks.push(buffer); - } - return Buffer.concat(chunks).toString("utf8"); -} - -/** - * Read an ask out of a parsed body, or return null. - * - * A blank description is rejected rather than relayed: the description IS the whole of what - * the human is shown, so an empty one is a prompt asking a person to approve nothing. - */ -export function parseAsk(body: unknown): PermissionAsk | null { - if (typeof body !== "object" || body === null || Array.isArray(body)) return null; - const record = body as Record; - const permId = record.perm_id; - const description = record.description; - if (typeof permId !== "string" || !PERM_ID_PATTERN.test(permId)) return null; - if (typeof description !== "string" || !description.trim()) return null; - return { - perm_id: permId, - product: typeof record.product === "string" ? record.product : "", - mode: typeof record.mode === "string" ? record.mode : "", - description, - }; -} - -/** - * Start one listener for one tool call. - * - * Per call, not per process: two concurrent calls get two ports and two tokens, so a - * callback for one run can never be answered by the other's elicitation. Port 0 lets the OS - * pick, which is also why the URL is read back off the bound address. - */ -export async function startCallbackListener( - onAsk: AskHandler, -): Promise { - const token = randomBytes(32).toString("hex"); - const sockets = new Set(); - - const server: Server = createServer((request, response) => { - void handle(request, response); - }); - - async function handle( - request: IncomingMessage, - response: ServerResponse, - ): Promise { - try { - const path = (request.url || "").split("?")[0]; - if (request.method !== "POST" || path !== CALLBACK_PATH) { - request.resume(); - respond(response, 404, { error: "not found" }); - return; - } - - // AUTH FIRST, before the body is read or parsed. A caller that cannot present the - // token gets no elicitation, no prompt, and nothing back that describes the run. - if (!tokenMatches(bearer(request.headers.authorization), token)) { - request.resume(); - logger.warn( - "askBrowserstackAI: rejected a permission callback with a bad or missing token", - ); - respond(response, 401, { error: "unauthorized" }); - return; - } - - let parsed: unknown; - try { - parsed = JSON.parse(await readBody(request)); - } catch { - // No usable `perm_id` to echo, so there is no valid 200 to send. A non-200 is a - // deny on Atlas's side, which is the right answer to a body we cannot read. - respond(response, 400, { error: "malformed body" }); - return; - } - - const ask = parseAsk(parsed); - if (!ask) { - respond(response, 400, { error: "malformed permission ask" }); - return; - } - - const decision = await onAsk(ask); - respond(response, 200, { - // Echoed exactly. Atlas treats a mismatch as a deny, and so should it. - perm_id: ask.perm_id, - decision: decision.decision, - reason: decision.reason, - }); - } catch (error) { - logger.error( - "askBrowserstackAI: permission callback failed: %s", - error instanceof Error ? error.message : String(error), - ); - // Fail closed. Atlas maps a non-200 to a deny and records `error_relay`. - if (!response.headersSent) respond(response, 500, { error: "relay failed" }); - else response.end(); - } - } - - server.on("connection", (socket) => { - sockets.add(socket); - socket.on("close", () => sockets.delete(socket)); - }); - - // A callback is held open for as long as the human takes to answer. Node's default - // 300s `requestTimeout` would cut that off at almost exactly the elicitation budget, so - // the request timeout is disabled and the elicitation's own 270s is the only clock. - server.requestTimeout = 0; - server.headersTimeout = 60_000; - - await new Promise((resolve, reject) => { - server.once("error", reject); - // LOOPBACK ONLY. Binding 0.0.0.0 would publish an approval prompt to the network. - server.listen(0, "127.0.0.1", () => { - server.removeListener("error", reject); - resolve(); - }); - }); - - const address = server.address() as AddressInfo; - return { - url: `http://127.0.0.1:${address.port}${CALLBACK_PATH}`, - token, - close(): Promise { - return new Promise((resolve) => { - // Destroy first: `close()` alone waits out idle keep-alive connections, and this - // runs in a `finally` that must not be able to hang the tool call. - for (const socket of sockets) socket.destroy(); - sockets.clear(); - server.close(() => resolve()); - }); - }, - }; -} diff --git a/src/tools/ask-browserstack/config.ts b/src/tools/ask-browserstack/config.ts index 8fedd82..6d94a71 100644 --- a/src/tools/ask-browserstack/config.ts +++ b/src/tools/ask-browserstack/config.ts @@ -15,7 +15,7 @@ import logger from "../../logger.js"; * * MCP client -> tool call longest, client-side, not ours * POST /agent HTTP request 330s <- here - * Atlas gate -> callback POST 300s Atlas's `permission_relay_timeout` + * Atlas gate -> stream ask 300s Atlas's `permission_relay_timeout` * elicitInput 270s <- here * * 300s is the browser path's existing PERMISSION_TIMEOUT, which also auto-rejects. @@ -101,7 +101,8 @@ function announce(what: string, url: string, source: "env" | "default"): void { */ export function atlasBaseUrl(): string { const explicit = process.env.ASK_BROWSERSTACK_ATLAS_URL; - const url = explicit && explicit.trim() ? trimUrl(explicit) : DEFAULT_ATLAS_URL; + const url = + explicit && explicit.trim() ? trimUrl(explicit) : DEFAULT_ATLAS_URL; announce("Atlas", url, explicit && explicit.trim() ? "env" : "default"); return url; } @@ -121,6 +122,10 @@ export function authTokenUrl(): string { const explicit = process.env.ASK_BROWSERSTACK_AUTH_TOKEN_URL; const url = explicit && explicit.trim() ? trimUrl(explicit) : DEFAULT_AUTH_TOKEN_URL; - announce("auth token endpoint", url, explicit && explicit.trim() ? "env" : "default"); + announce( + "auth token endpoint", + url, + explicit && explicit.trim() ? "env" : "default", + ); return url; } diff --git a/src/tools/ask-browserstack/egress.ts b/src/tools/ask-browserstack/egress.ts index 609e021..1e89128 100644 --- a/src/tools/ask-browserstack/egress.ts +++ b/src/tools/ask-browserstack/egress.ts @@ -1,9 +1,11 @@ /** - * The outbound `POST /agent`, behind a seam. + * The pieces of the outbound `POST /agent` that are not the transport itself. * - * The seam is the point: the Atlas half of this feature is being built in parallel and does - * not exist yet, so every test substitutes this rather than reaching a live service — the - * same role `RegistryDeps.transport` plays for the capability registry. + * The transport moved to `stream.ts` when A2 was removed: `/agent` is read as an event + * stream now, so a one-request-one-response `AgentTransport` has nothing left to describe. + * What stays here is what both halves always shared — the header set, the credential pair, + * and the response shape `relay.ts` reads to tell a refusal from an unreachable service + * apart, which the stream's JSON-degrade path still produces. * * AUTH HERE IS NOT THE PRODUCT-API AUTH. `/agent` accepts exactly two credentials, both in * `Authorization`: the shared delegation token or a BrowserStack central JWT. There is no @@ -14,9 +16,6 @@ * one, and is deliberately not imported here. */ -import { AGENT_TIMEOUT_MS } from "./config.js"; -import { AgentRequest } from "./types.js"; - export interface Credentials { username: string; accessKey: string; @@ -43,47 +42,3 @@ export interface AgentResponse { /** Only when there was no response at all to speak for itself. */ error?: string; } - -export type AgentTransport = ( - url: string, - headers: Record, - body: AgentRequest, -) => Promise; - -/** - * A fetch-based transport. - * - * The 330s budget is the outer rung of CONTRACT §4's ladder: it must outlast Atlas's own - * 300s gate timeout, which must in turn outlast our 270s elicitation, or a layer dies before - * the layer it is waiting on can answer. - */ -export function fetchAgentTransport( - timeoutMs = AGENT_TIMEOUT_MS, -): AgentTransport { - return async (url, headers, body) => { - const controller = new AbortController(); - const timer = setTimeout(() => controller.abort(), timeoutMs); - try { - const response = await fetch(url, { - method: "POST", - headers, - 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. - redirect: "manual", - signal: controller.signal, - }); - let parsed: unknown = null; - const contentType = response.headers.get("content-type") || ""; - if (contentType.includes("json")) { - parsed = await response.json().catch(() => null); - } - return { status: response.status, body: parsed }; - } catch { - // Upstream detail stays out of the reply; status 0 is read as a failed call. - return { status: 0, body: null, error: "BrowserStack AI could not be reached" }; - } finally { - clearTimeout(timer); - } - }; -} diff --git a/src/tools/ask-browserstack/register.ts b/src/tools/ask-browserstack/register.ts index 9cb6dda..d9f8ed7 100644 --- a/src/tools/ask-browserstack/register.ts +++ b/src/tools/ask-browserstack/register.ts @@ -24,7 +24,10 @@ * itself because a headless client returns `cancel`, which is a deny. */ -import { McpServer, RegisteredTool } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + McpServer, + RegisteredTool, +} from "@modelcontextprotocol/sdk/server/mcp.js"; import { CallToolResult, ElicitResult, @@ -45,11 +48,7 @@ import { isEnabled, } from "./config.js"; import { fetchTokenTransport, mintCentralToken } from "./central-oauth.js"; -import { AgentTransport, Credentials, agentHeaders } from "./egress.js"; -// A2's transport lives on per CONTRACT v2 §7.5 and nothing here calls it: until every -// Atlas serves A1, deleting `callback.ts` would leave a co-located caller with no working -// transport at all. Only the TYPE is needed, to keep `AskDeps.startListener` declared. -import { startCallbackListener } from "./callback.js"; +import { Credentials, agentHeaders } from "./egress.js"; import { EVENT_PERMISSION, EVENT_RESULT, @@ -57,6 +56,7 @@ import { decisionUrl, fetchAgentStreamTransport, fetchDecisionTransport, + parseAsk, } from "./stream.js"; import type { AgentStreamTransport, DecisionTransport } from "./stream.js"; import { @@ -95,13 +95,9 @@ export interface AskDeps { * as the human rather than as a shared service account. */ credentialsFor: () => Credentials; - /** A2's seam. Unused by the tool today; kept while callback.ts is (v2 §7.5). */ - transport?: AgentTransport; - /** A2's seam. Same. */ - startListener?: typeof startCallbackListener; /** - * A1's seams. Injectable for the same reason A2's were: a test must be able to drive - * a whole approval round trip — ask, elicit, decide, result — without a socket. + * The two transport seams, injectable so a test can drive a whole approval round trip + * — ask, elicit, decide, result — without a socket. */ streamTransport?: AgentStreamTransport; decisionTransport?: DecisionTransport; @@ -139,7 +135,8 @@ const DESCRIPTION = * `permission_relay` reasons exist to prevent. `ok` still means `status === "ok"`. */ function toResult(payload: AskResult): CallToolResult { - const failed = payload.status === "error" || payload.status === "rate_limited"; + const failed = + payload.status === "error" || payload.status === "rate_limited"; return { content: [{ type: "text", text: JSON.stringify(payload) }], ...(failed ? { isError: true } : {}), @@ -202,8 +199,9 @@ async function relayOneAsk( return { perm_id: ask.perm_id, decision: "deny", reason: "timeout" }; } // An unexpected failure has no honest `reason` in CONTRACT §2's vocabulary, so it is - // not given one: rethrowing makes the callback answer 500, which Atlas's fail-closed - // rule already reads as a deny and records as `error_relay`. + // not given one on the wire: the throw says "this side broke" without claiming a human + // decided anything. `runStreamed` catches it and sends the explicit deny the throw + // implies — see the comment there for why A1 cannot let it escape. approvals.push({ description: ask.description, decision: "deny", @@ -215,7 +213,10 @@ async function relayOneAsk( // The SHAPE of the answer only — a fixed action enum and a boolean, never the description // or anything a user typed. Logged so that what a client actually submits can be read next // time rather than inferred from a compiled binary. - logger.info("askBrowserstackAI: elicitation answered %s", elicitationShape(answer)); + logger.info( + "askBrowserstackAI: elicitation answered %s", + elicitationShape(answer), + ); const { decision, reason } = decide(answer); approvals.push({ description: ask.description, decision, reason }); @@ -226,38 +227,35 @@ async function relayOneAsk( * Decide whether to offer the approval channel at all — and in the hosted deployment, do not. * * THE RELAY IS A STDIO-ONLY FEATURE, and that is a designed property rather than an accident. - * Three things break in `REMOTE_MCP` mode, in increasing order of how hard they are to fix: + * A1 fixed the two reachability problems A2 had — nothing binds a loopback port per tool + * call, and Atlas never dials back, so its SSRF allowlist is not in the path at all — but it + * cannot fix the one that actually blocks remote mode: * - * 1. The callback listener binds `127.0.0.1:` PER TOOL CALL. In the shared, - * multi-tenant process that is N concurrent listeners on one host, with the per-run - * bearer as the only thing keeping tenants apart. - * 2. Atlas cannot reach it anyway. A `127.0.0.1` callback URL means the ATLAS POD'S OWN - * loopback, so every remote call is refused by its SSRF allowlist as `host_not_allowed`. - * Confirmed live against staging. - * 3. Elicitation is a SERVER-INITIATED message, and the remote `/mcp` is stateless BY - * DELIBERATE DESIGN. Commit `841c6358` removed sessions because they broke behind two - * replicas — "Session not found on roughly half of every client's post-handshake calls" - * — and justified it precisely on the grounds that "we use neither server-initiated - * messages nor subscriptions/sampling". This feature is the exception that commit did - * not have to consider, and it needs the machinery that commit removed. + * Elicitation is a SERVER-INITIATED message, and the remote `/mcp` is stateless BY + * DELIBERATE DESIGN. Commit `841c6358` removed sessions because they broke behind two + * replicas — "Session not found on roughly half of every client's post-handshake calls" + * — and justified it precisely on the grounds that "we use neither server-initiated + * messages nor subscriptions/sampling". This feature is the exception that commit did + * not have to consider, and it needs the machinery that commit removed. * - * So do not start the listener and do not send `permission_relay`: Atlas then runs read-only, - * which is a supported path that already works. Attempting the relay instead would buy a slow - * and confusing failure in place of a clear one. + * So do not send `permission_relay` in remote mode: Atlas then runs read-only, which is a + * supported path that already works. Attempting the relay instead would buy a slow and + * confusing failure in place of a clear one — the run would stream asks nobody can be shown, + * and every one of them would expire into a deny 300s later. * - * BEFORE RE-ENABLING THIS IN REMOTE MODE: the blocker is (3), not configuration. It needs - * PLAN.md's option (c) — resolve the run in Postgres and nudge the pod holding the waiter over - * Redis, the pattern `POST /api/agent-callback/{correlation_id}` already uses — plus a callback - * URL that addresses a specific replica. A config flag will not do it. + * BEFORE RE-ENABLING THIS IN REMOTE MODE: the blocker is server-initiated messaging, not + * configuration, and A1 does NOT need a per-replica address any more (the decision is an + * ordinary inbound POST that Atlas routes to the owning pod itself). What is still missing is + * a way for a stateless replica to prompt the client. A config flag will not do it. */ /** * CONTRACT v2 (A1) — drive one run over the stream. * * The loop is the whole orchestration: read events, elicit on each `permission`, POST * the decision, hand the `result` to `buildResult`. It decides nothing itself — - * `relayOneAsk` still owns the elicitation and the allow/deny mapping, and it is the - * SAME function the callback transport used. That is deliberate: the transport changed, - * the judgement did not. + * `relayOneAsk` owns the elicitation and the allow/deny mapping, unchanged from the + * transport it replaced. That is deliberate: the transport changed, the judgement did + * not, and the judgement is the part that is dangerous to get wrong. * * Reading pauses while a human is being prompted, which is correct rather than merely * tolerable: Atlas is blocked on that decision and will emit nothing but heartbeats @@ -298,7 +296,15 @@ async function runStreamed( } if (event.event !== EVENT_PERMISSION) continue; - const ask = event.data as PermissionAsk; + // Validated, not cast: a frame missing a usable `perm_id` or carrying a blank + // description cannot produce an answerable prompt, so it must not produce a prompt. + const ask = parseAsk(event.data); + if (!ask) { + logger.error( + "askBrowserstackAI: unusable permission ask on the stream; ignoring", + ); + continue; + } if (!runId) { // Atlas emits `run` before any ask precisely so this cannot happen. If it does, // there is nowhere to send a decision — so do not prompt a human for an answer @@ -326,15 +332,11 @@ async function runStreamed( ); decision = { perm_id: ask.perm_id, decision: "deny", reason: "error" }; } - const status = await decisionTransport( - decisionUrl(url, runId), - headers, - { - perm_id: decision.perm_id, - decision: decision.decision, - reason: decision.reason || "", - }, - ); + const status = await decisionTransport(decisionUrl(url, runId), headers, { + perm_id: decision.perm_id, + decision: decision.decision, + reason: decision.reason || "", + }); if (status !== 204) { // Never fatal, and never re-sent. Atlas's gate is still waiting and denies on its // own expiry, so a lost decision is safe — it can only cost an approval, never @@ -355,9 +357,14 @@ async function runStreamed( approvals, ); } - // Shaped as an `AgentResponse` so `buildResult` — shared with the callback transport + // Shaped as an `AgentResponse` so `buildResult` — written for the transport A1 replaced // and unchanged — sees exactly what it always saw. - return buildResult({ status: resultStatus, body: result }, approvals, mode, product); + return buildResult( + { status: resultStatus, body: result }, + approvals, + mode, + product, + ); } export function relayMode(server: McpServer): RelayMode { @@ -372,11 +379,10 @@ export function addAskBrowserstackAITool( deps: AskDeps, config?: BrowserStackConfig, ): Record { - // A1 (CONTRACT v2) is the path. `transport`/`startListener` remain wired for the - // callback transport, which stays on disk per v2 §7.5 — until every Atlas serves A1, - // deleting it would leave nothing that works for a co-located caller. Nothing calls - // it today; the stream degrades to a read-only JSON answer against an older Atlas, - // so no version flag is needed to be safe. + // A1 (CONTRACT v2) is the only path; A2 is gone. No version flag is needed to talk to + // an Atlas that predates the stream: such a server answers `POST /agent` with ordinary + // JSON, the parser sees no `text/event-stream`, and the run degrades to a read-only + // answer carrying that response's own status. const streamTransport = deps.streamTransport || fetchAgentStreamTransport(); const decisionTransport = deps.decisionTransport || fetchDecisionTransport(); const tools: Record = {}; @@ -456,8 +462,15 @@ export function addAskBrowserstackAITool( // asking about the wrong thing. return toResult( await runStreamed( - server, streamTransport, decisionTransport, - url, headers, body, approvals, mode, product, + server, + streamTransport, + decisionTransport, + url, + headers, + body, + approvals, + mode, + product, ), ); } catch (error) { diff --git a/src/tools/ask-browserstack/relay.ts b/src/tools/ask-browserstack/relay.ts index 12d73b0..f453578 100644 --- a/src/tools/ask-browserstack/relay.ts +++ b/src/tools/ask-browserstack/relay.ts @@ -58,11 +58,15 @@ export const RELAY_OFF_DETAILS: Record = { "configuration reason alone. Retrying will keep failing the same way until an " + "administrator turns the relay on.", + // ONLY AN ATLAS THAT PREDATES A1 CAN SEND THIS. It described the old outbound transport: + // the server was handed a URL to call back and refused the address. Nothing dials out any + // more, so the condition cannot arise — the reason stays mapped because an older + // deployment is still entitled to an explanation rather than a raw enum. host_not_allowed: - "BrowserStack refused to call this client back: the loopback address it was given is " + - "not on the server's allowed-callback list, which is the guard that stops a " + - "caller-supplied URL turning the server into a request proxy. The run went read-only. " + - "This normally means BrowserStack is not running on the same host as this MCP server.", + "BrowserStack refused to call this client back, which means it is running a version " + + "that predates the current approval channel: that version could only reach a client on " + + "the same host. The run went read-only. Nothing was declined by a person, and the fix " + + "is a BrowserStack-side upgrade rather than anything about this request.", malformed: "BrowserStack could not use the approval channel this client offered and ran read-only. " + @@ -73,9 +77,9 @@ export const RELAY_OFF_DETAILS: Record = { // change either of them can make will help. remote_mode: "NOBODY DECLINED THIS, AND YOUR CLIENT IS NOT THE PROBLEM. This BrowserStack MCP server " + - "is running in its hosted, multi-tenant mode, which has no way to receive the approval " + - "callback, so it ran read-only and everything in `needs_approval` was refused for that " + - "reason alone. Mid-run approval works when the server runs locally over stdio; retrying " + + "is running in its hosted, multi-tenant mode, which has no way to put an approval " + + "prompt in front of you, so it ran read-only and everything in `needs_approval` was " + + "refused for that reason alone. Mid-run approval works when the server runs locally over stdio; retrying " + "against this deployment will keep failing the same way.", // Not a refusal by anyone and not a relay problem at all: the account is not on the @@ -205,7 +209,8 @@ export function atlasRelayVerdict( payload: Record, ): { used: boolean; reason: string } | null { const raw = payload.permission_relay; - if (typeof raw !== "object" || raw === null || Array.isArray(raw)) return null; + if (typeof raw !== "object" || raw === null || Array.isArray(raw)) + return null; const record = raw as Record; if (typeof record.used !== "boolean") return null; return { @@ -230,7 +235,10 @@ export const PRODUCT_LABELS: Record = { tra: "Test Reporting & Analytics", }; -export function elicitationMessage(product: string, description: string): string { +export function elicitationMessage( + product: string, + description: string, +): string { const label = PRODUCT_LABELS[product] || product.trim(); const who = label ? `BrowserStack AI (${label})` @@ -273,7 +281,8 @@ export function decide(result: ElicitResult): { } return { decision: "allow", reason: "" }; } - if (result.action === "decline") return { decision: "deny", reason: "declined" }; + if (result.action === "decline") + return { decision: "deny", reason: "declined" }; // `cancel`, and anything a future client sends that we do not recognise: no explicit // answer was given, which is not an answer we may read as yes. return { decision: "deny", reason: "cancelled" }; @@ -339,10 +348,12 @@ export function parseAtlasApprovals( if (!Array.isArray(raw)) return null; const trail: ApprovalRecord[] = []; for (const item of raw) { - if (typeof item !== "object" || item === null || Array.isArray(item)) continue; + if (typeof item !== "object" || item === null || Array.isArray(item)) + continue; const entry = item as Record; trail.push({ - description: typeof entry.description === "string" ? entry.description : "", + description: + typeof entry.description === "string" ? entry.description : "", decision: entry.decision === "allow" ? "allow" : "deny", reason: typeof entry.reason === "string" ? entry.reason : "", ...(typeof entry.applied === "boolean" ? { applied: entry.applied } : {}), @@ -483,11 +494,19 @@ function relayVerdict( // client that CAN be prompted is of no use, so the deployment is the binding constraint and // the one the reader can act on; telling them to switch clients would waste their time. if (mode === "remote_mode") { - return { used: false, reason: "remote_mode", detail: RELAY_OFF_DETAILS.remote_mode }; + return { + used: false, + reason: "remote_mode", + detail: RELAY_OFF_DETAILS.remote_mode, + }; } if (mode === "no_human") { // CONTRACT §7's last row: no elicitation capability means the field was never sent. - return { used: false, reason: "no_human", detail: RELAY_OFF_DETAILS.no_human }; + return { + used: false, + reason: "no_human", + detail: RELAY_OFF_DETAILS.no_human, + }; } const verdict = atlasRelayVerdict(payload); if (verdict) { @@ -515,14 +534,19 @@ export function buildResult( ? (payload.needs_approval as unknown[]) : []; // ATLAS'S TRAIL WINS WHEN IT SENT ONE. It is the only side that can fill in `applied`, and - // it records what happened to the STEP: a callback answered without a prompt appearing is - // a denial there and nothing at all here. Ours is kept separately rather than discarded, - // because that difference is exactly how a probe of the loopback port shows up. + // it records what happened to the STEP: an ask answered without a prompt appearing is a + // denial there and nothing at all here. Ours is kept separately rather than discarded, + // because that difference is exactly how an answer this side never prompted for shows up. const atlasTrail = parseAtlasApprovals(payload); const trail = atlasTrail ?? approvals; const status = deriveStatus(response, trail, needsApproval); const reachedAgent = !neverReachedAgent(response); - const relay = relayVerdict(payload, mode, reachedAgent, isNotEntitled(response)); + const relay = relayVerdict( + payload, + mode, + reachedAgent, + isNotEntitled(response), + ); // Correct the sentence when the channel was fine but nobody was ever behind it. The // verdict is computed from the MODE and Atlas's advisory field, neither of which can // see what the elicitation actually returned — so only here, with the trail in hand, @@ -630,7 +654,10 @@ function atlasError( * was granted is exactly the case where a caller most needs to know something may already * have been applied. */ -export function errorResult(message: string, approvals: ApprovalRecord[]): AskResult { +export function errorResult( + message: string, + approvals: ApprovalRecord[], +): AskResult { return { ok: false, status: "error", diff --git a/src/tools/ask-browserstack/stream.ts b/src/tools/ask-browserstack/stream.ts index d578041..307a7c8 100644 --- a/src/tools/ask-browserstack/stream.ts +++ b/src/tools/ask-browserstack/stream.ts @@ -25,7 +25,7 @@ import logger from "../../logger.js"; import { AskError } from "./config.js"; -import type { AgentRequest } from "./types.js"; +import type { AgentRequest, PermissionAsk } from "./types.js"; /** One SSE frame, already parsed. `data` is whatever JSON the frame carried. */ export interface StreamEvent { @@ -55,10 +55,47 @@ export const EVENT_RUN = "run"; export const EVENT_PERMISSION = "permission"; export const EVENT_RESULT = "result"; +/** Atlas's `f"perm-{uuid.uuid4().hex}"`, and nothing else. */ +export const PERM_ID_PATTERN = /^perm-[0-9a-f]{32}$/; + +/** + * Read an ask out of a `permission` frame's data, or return null. + * + * Came over from the transport this one replaced, and the reasons it existed did not + * change with the transport — only the direction the ask arrives from did. It is not a + * trust check on Atlas: it is what keeps a malformed frame from turning into a prompt + * that cannot be honoured. + * + * A blank description is rejected rather than relayed: the description IS the whole of + * what the human is shown, so an empty one is a prompt asking a person to approve + * nothing. A `perm_id` off Atlas's shape is rejected because it is the only thing that + * routes the answer back — the decision endpoint matches on it, so an id we could not + * have received is an answer that can never be delivered. + * + * Only the four fields of CONTRACT §2 are carried forward. `op_key`, `method`, `path` + * and `host` are Atlas-private (v1.1 §A) and it does not send them; if a future one + * ever did, they would stop here rather than reach an elicitation prompt or a result. + */ +export function parseAsk(data: unknown): PermissionAsk | null { + if (typeof data !== "object" || data === null || Array.isArray(data)) + return null; + const record = data as Record; + const permId = record.perm_id; + const description = record.description; + if (typeof permId !== "string" || !PERM_ID_PATTERN.test(permId)) return null; + if (typeof description !== "string" || !description.trim()) return null; + return { + perm_id: permId, + product: typeof record.product === "string" ? record.product : "", + mode: typeof record.mode === "string" ? record.mode : "", + description, + }; +} + /** - * The A1 transport seam, mirroring `AgentTransport` but yielding many events instead of - * returning one body. Injectable for the same reason that one is: the tests must be able - * to drive a whole approval round trip without a socket. + * The transport seam: the shape `AgentTransport` had before A2 was removed, except that it + * yields many events instead of returning one body. Injectable for the same reason that one + * was: the tests must be able to drive a whole approval round trip without a socket. */ export type AgentStreamTransport = ( url: string, @@ -82,7 +119,10 @@ export type DecisionTransport = ( * ask that is silently dropped — a write that never gets approved and never explains * why — so it is tested directly rather than only through the happy path. */ -export function splitFrames(buffer: string): { frames: string[]; rest: string } { +export function splitFrames(buffer: string): { + frames: string[]; + rest: string; +} { const frames: string[] = []; let rest = buffer; for (;;) { @@ -168,7 +208,11 @@ export function fetchAgentStreamTransport( // parse failure against a body that was never SSE. if (contentType.includes("json")) { const parsed = await response.json().catch(() => null); - yield { event: EVENT_RESULT, data: parsed, status: response.status }; + yield { + event: EVENT_RESULT, + data: parsed, + status: response.status, + }; return; } diff --git a/src/tools/ask-browserstack/types.ts b/src/tools/ask-browserstack/types.ts index d273799..df99af7 100644 --- a/src/tools/ask-browserstack/types.ts +++ b/src/tools/ask-browserstack/types.ts @@ -10,7 +10,7 @@ export const PRODUCTS = ["tm", "a11y", "tra"] as const; export type Product = (typeof PRODUCTS)[number]; -/** CONTRACT §2 — the body Atlas POSTs to our callback when its gate needs a human. */ +/** CONTRACT §2 — what Atlas emits on the run's stream when its gate needs a human. */ export interface PermissionAsk { /** * Atlas's own `perm-<32 hex>` uuid4, carried, never re-minted. @@ -50,7 +50,7 @@ export type DecisionReason = // It appears only in `ApprovalRecord.reason`, where the caller can see what happened. | "error"; -/** CONTRACT §2 — what we answer the still-open callback request with. */ +/** CONTRACT §2 — the body we POST to `/agent/{run_id}/permission` to answer one ask. */ export interface PermissionDecision { perm_id: string; decision: Decision; @@ -59,21 +59,20 @@ export interface PermissionDecision { /** CONTRACT §1 — the one new optional field on `POST /agent`. */ /** - * The `permission_relay` block. Two shapes, one per transport: + * The `permission_relay` block — one shape, because there is one transport. * - * * `{ mode: "stream" }` — A1 / CONTRACT v2. Nothing to address and nothing to - * authenticate inbound, because nothing dials in. This is what ships. - * * `{ callback_url, token }` — A2 / CONTRACT v1. Retained for a co-located caller - * until every Atlas serves A1 (v2 §7.5); unused by the tool today. + * `{ mode: "stream" }` is A1 / CONTRACT v2: nothing to address and nothing to + * authenticate inbound, because nothing dials in. A2's `{ callback_url, token }` was + * removed on both halves (v2 §7.4) — it could only ever reach a co-located caller, and + * an Atlas that still sees that shape now reports the relay as `disabled` rather than + * trying it. * - * Both optional rather than a union so a caller cannot half-fill either one: Atlas - * checks for `mode` first, so a block carrying both is read as a stream and never - * silently routed onto the transport that cannot reach the caller. + * `mode` stays optional rather than required so this stays a superset of the block an + * older Atlas will simply ignore: the field's ABSENCE is what selects the read-only + * gate, and that has to remain expressible. */ export interface PermissionRelay { mode?: string; - callback_url?: string; - token?: string; } /** @@ -138,11 +137,11 @@ export interface ApprovalRecord { * thing from whoever reads the result — so they are three values rather than one boolean. */ export type RelayMode = - /** A callback listener was bound and `permission_relay` was sent. */ + /** `permission_relay` was sent and the run's stream carried the asks. */ | "offered" /** The client declares no `elicitation` capability, so nobody could be prompted. */ | "no_human" - /** This process is the hosted multi-tenant server, which cannot receive the callback. */ + /** This process is the hosted multi-tenant server, which cannot prompt a human. */ | "remote_mode"; export const ASK_STATUSES = ["ok", "blocked", "error", "rate_limited"] as const; @@ -157,7 +156,7 @@ export interface AskResult { * THE AUTHORITATIVE approval trail: Atlas's whenever it supplied one, ours otherwise. * * Atlas's wins because it is the only side that can populate `applied`, and because it - * records what happened to the STEP — a callback answered without a prompt appearing (a + * records what happened to the STEP — an ask answered without a prompt appearing (a * 401'd probe, a shape rejection) is a denial there and nothing at all here. */ approvals: ApprovalRecord[]; @@ -168,7 +167,7 @@ export interface AskResult { * * Kept beside `approvals` rather than folded into it, because where the two disagree the * disagreement is the signal. An entry Atlas records as a denial with nothing here means a - * callback was answered without any prompt appearing — which is what an attacker probing + * ask was answered without any prompt appearing — which is what an attacker probing * the loopback port looks like. */ elicitations: ApprovalRecord[]; diff --git a/tests/tools/askBrowserstack.test.ts b/tests/tools/askBrowserstack.test.ts index 82b76a1..26e1e21 100644 --- a/tests/tools/askBrowserstack.test.ts +++ b/tests/tools/askBrowserstack.test.ts @@ -1,11 +1,6 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { - CALLBACK_PATH, - CallbackListener, - parseAsk, - startCallbackListener, -} from "../../src/tools/ask-browserstack/callback.js"; +import { parseAsk } from "../../src/tools/ask-browserstack/stream.js"; import { AskError, DEFAULT_ATLAS_URL, @@ -54,27 +49,6 @@ function ask(overrides: Record = {}) { }; } -async function post( - listener: CallbackListener, - body: unknown, - token: string | null = listener.token, - path = CALLBACK_PATH, - method = "POST", -) { - const response = await fetch( - listener.url.replace(CALLBACK_PATH, path), - { - method, - headers: { - "Content-Type": "application/json", - ...(token === null ? {} : { Authorization: `Bearer ${token}` }), - }, - body: typeof body === "string" ? body : JSON.stringify(body), - }, - ); - return { status: response.status, body: await response.json().catch(() => null) }; -} - describe("decide — CONTRACT §7, and nothing but", () => { it("allows an accept whose form carried no confirm field at all", () => { // THE LIVE BUG. `confirm` used to be required with `default: false`, so a client @@ -205,9 +179,9 @@ describe("the authoritative approval trail (D4)", () => { }); it("keeps our own trail beside it, because the disagreement IS the signal", () => { - // A callback answered with no prompt appearing — an attacker probing the loopback port - // — is a denial to Atlas and nothing at all to us. Folding the two together would - // destroy the only evidence that it happened. + // An ask Atlas recorded as answered while no prompt ever appeared on this side is a + // denial to Atlas and nothing at all to us. Folding the two together would destroy + // the only evidence that it happened. const result = build({ approvals: [{ description: "Creating the Alpha folder", decision: "deny", reason: "error", applied: false }], }, []); @@ -355,105 +329,8 @@ describe("result assembly", () => { }); }); -describe("the loopback callback listener", () => { - const open: CallbackListener[] = []; - - afterEach(async () => { - while (open.length) await open.pop()!.close(); - }); - - async function listen(handler: Parameters[0]) { - const listener = await startCallbackListener(handler); - open.push(listener); - return listener; - } - - it("binds loopback on a port the OS chose, never 0.0.0.0 and never a fixed one", async () => { - const first = await listen(async () => ({ perm_id: PERM, decision: "deny", reason: "" })); - const second = await listen(async () => ({ perm_id: PERM, decision: "deny", reason: "" })); - - expect(first.url).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/atlas-permission$/); - expect(first.url).not.toBe(second.url); - // Two concurrent calls must not be able to answer each other's asks. - expect(first.token).not.toBe(second.token); - expect(first.token).toHaveLength(64); - }); - - it("answers a properly authenticated ask, echoing perm_id exactly", async () => { - const seen: string[] = []; - const listener = await listen(async (incoming) => { - seen.push(incoming.description); - return { perm_id: incoming.perm_id, decision: "allow", reason: "" }; - }); - - const response = await post(listener, ask()); - expect(response.status).toBe(200); - expect(response.body).toEqual({ perm_id: PERM, decision: "allow", reason: "" }); - expect(seen).toEqual(["Create the folder \"Regression\" under Sprint 42."]); - }); - - it("401s a callback with a wrong or missing token, and elicits NOTHING", async () => { - // A stray local process must not be able to make an approval prompt appear. - const handler = vi.fn(); - const listener = await listen(handler as never); - - expect((await post(listener, ask(), null)).status).toBe(401); - expect((await post(listener, ask(), "")).status).toBe(401); - expect((await post(listener, ask(), "not-the-token")).status).toBe(401); - expect((await post(listener, ask(), listener.token + "x")).status).toBe(401); - expect(handler).not.toHaveBeenCalled(); - }); - - it("refuses a perm_id that is not Atlas's shape, without asking anyone", async () => { - const handler = vi.fn(); - const listener = await listen(handler as never); - - expect((await post(listener, ask({ perm_id: "perm-nope" }))).status).toBe(400); - expect((await post(listener, ask({ perm_id: "1234" }))).status).toBe(400); - expect((await post(listener, ask({ perm_id: undefined }))).status).toBe(400); - expect(handler).not.toHaveBeenCalled(); - }); - - it("refuses a blank description: a prompt asking a human to approve nothing", async () => { - const handler = vi.fn(); - const listener = await listen(handler as never); - expect((await post(listener, ask({ description: " " }))).status).toBe(400); - expect(handler).not.toHaveBeenCalled(); - }); - - it("refuses a body it cannot parse", async () => { - const handler = vi.fn(); - const listener = await listen(handler as never); - expect((await post(listener, "{not json")).status).toBe(400); - expect(handler).not.toHaveBeenCalled(); - }); - - it("404s anything that is not a POST to the callback path", async () => { - const handler = vi.fn(); - const listener = await listen(handler as never); - expect((await post(listener, ask(), listener.token, "/", "POST")).status).toBe(404); - expect((await post(listener, ask(), listener.token, CALLBACK_PATH, "PUT")).status).toBe(404); - expect(handler).not.toHaveBeenCalled(); - }); - - it("fails closed with a non-200 when the relay itself throws", async () => { - const listener = await listen(async () => { - throw new Error("client went away"); - }); - // Atlas maps a non-200 to a deny, so a broken relay cannot approve anything. - expect((await post(listener, ask())).status).toBe(500); - }); - - it("stops accepting connections once closed", async () => { - const listener = await startCallbackListener(async (incoming) => ({ - perm_id: incoming.perm_id, decision: "allow", reason: "", - })); - const url = listener.url; - await listener.close(); - await expect(fetch(url, { method: "POST", body: "{}" })).rejects.toThrow(); - }); -}); - +// The transport it was written for is gone; every reason it exists survived the move, +// because they are all properties of the ASK rather than of how the ask arrived. describe("parseAsk", () => { it("keeps only the four fields of CONTRACT §2", () => { // op_key, method, path and host stay on Atlas's side; if one ever arrived it would not @@ -467,6 +344,30 @@ describe("parseAsk", () => { expect(parseAsk([ask()])).toBeNull(); expect(parseAsk("perm-x")).toBeNull(); }); + + it("refuses a perm_id that is not Atlas's shape", () => { + // It is the only thing that routes the decision back, so an id we could not have + // been sent is an answer that could never be delivered. Better to drop the frame + // than to prompt a human for a decision with nowhere to go. + expect(parseAsk(ask({ perm_id: "perm-nope" }))).toBeNull(); + expect(parseAsk(ask({ perm_id: "1234" }))).toBeNull(); + expect(parseAsk(ask({ perm_id: undefined }))).toBeNull(); + expect(parseAsk(ask({ perm_id: PERM.toUpperCase() }))).toBeNull(); + }); + + it("refuses a blank description: a prompt asking a human to approve nothing", () => { + expect(parseAsk(ask({ description: " " }))).toBeNull(); + expect(parseAsk(ask({ description: "" }))).toBeNull(); + expect(parseAsk(ask({ description: 42 }))).toBeNull(); + }); + + it("defaults product and mode rather than rejecting, because neither is load-bearing", () => { + // `description` is the whole of what the human reads and `perm_id` is what routes + // the answer. These two only decorate the prompt, so a missing one degrades it + // instead of dropping an ask a person could still have answered. + expect(parseAsk(ask({ product: undefined, mode: undefined }))) + .toEqual({ ...ask(), product: "", mode: "" }); + }); }); @@ -537,9 +438,12 @@ describe("Atlas's permission_relay verdict — CONTRACT v1.1 §D", () => { }); it("gives host_not_allowed and malformed their own distinct sentences", () => { + // `host_not_allowed` can now only come from an Atlas older than A1 — the current one + // never dials out, so there is no host to refuse. The sentence stays mapped because + // such a deployment still deserves an explanation rather than a raw enum. const host = relayOf({ used: false, reason: "host_not_allowed" }).detail; const malformed = relayOf({ used: false, reason: "malformed" }).detail; - expect(host).toMatch(/allowed-callback list/); + expect(host).toMatch(/predates the current approval channel/); expect(host).toMatch(/same host/); expect(malformed).toMatch(/bug on this side/); expect(host).not.toBe(malformed); diff --git a/tests/tools/askBrowserstackE2E.test.ts b/tests/tools/askBrowserstackE2E.test.ts index 6e75d9b..5b596e8 100644 --- a/tests/tools/askBrowserstackE2E.test.ts +++ b/tests/tools/askBrowserstackE2E.test.ts @@ -6,9 +6,6 @@ import { AskError } from "../../src/tools/ask-browserstack/config.js"; import { resetTokenCache } from "../../src/tools/ask-browserstack/central-oauth.js"; import { addAskBrowserstackAITool } from "../../src/tools/ask-browserstack/register.js"; -/** Captured before anything stubs the global, so the loopback hop stays real. */ -const realFetch = globalThis.fetch.bind(globalThis); - const CONFIG = { "browserstack-username": "ing_Xx", "browserstack-access-key": "SECRET", @@ -692,19 +689,13 @@ describe("askBrowserstackAI, end to end through the server factory", () => { expect(payload.applied_before_stop).toBe(false); }); - it("tears the listener down once the call ends, even when the call failed", async () => { - const server = await buildServer(); - fakeClient(server.getInstance(), { elicitation: {} }, []); - const stub = atlas({ throws: true }); - - const { payload } = await call(server.getTools()); - expect(payload.ok).toBe(false); - expect(payload.status).toBe("error"); - - // The port must not survive the call that opened it. - const url = stub.calls[0].body.permission_relay.callback_url; - await expect(realFetch(url, { method: "POST", body: "{}" })).rejects.toThrow(); - }); + // REMOVED WITH A2: "tears the listener down once the call ends". Its subject was the + // ephemeral loopback port the callback transport opened per tool call, and A1 opens + // none — there is nothing left to leak. It had also gone vacuous before it was + // deleted: it read `permission_relay.callback_url` off a body that now carries only + // `{mode}`, so it was probing `undefined` and passing on the resulting throw. The + // half of it that still means something (a failed call reports the relay as + // `not_reached` and prompts nobody) is asserted below, against the same stub. }); describe("the client CANNOT elicit — the opencode/goose path", () => { @@ -1137,12 +1128,13 @@ describe("askBrowserstackAI, end to end through the server factory", () => { expect(payload.needs_approval).toEqual(["Create folder \"Regression\"."]); }); - it("binds no port at all — the listener is never even constructed", async () => { - // Not "bound and left to fail on a callback that cannot arrive": never bound. In the - // shared process that would be one ephemeral listener per concurrent tool call. + it("offers no relay at all — `permission_relay` is never put on the body", async () => { + // Not "offered and left to fail on an ask nobody can be shown": never offered. The + // ask channel A1 uses needs a server-initiated elicitation, which the stateless + // hosted `/mcp` cannot do across replicas (v2 §5). // // Asserted through the injected seam rather than a module spy, so a negative result - // means the code did not call it — not that the spy failed to attach. The positive + // means the code did not send it — not that the spy failed to attach. The positive // control below is what makes this assertion mean anything. vi.resetModules(); process.env.REMOTE_MCP = "true"; @@ -1270,7 +1262,7 @@ describe("askBrowserstackAI, against the injected seam", () => { it("refuses rather than calling Atlas unauthenticated, and never names the token", async () => { const mcp = new McpServer({ name: "t", version: "0" }); - const transport = vi.fn(); + const streamed = vi.fn(); const tools = addAskBrowserstackAITool(mcp, { agentUrl: () => "https://atlas.example/agent", mintToken: async () => { @@ -1280,14 +1272,15 @@ describe("askBrowserstackAI, against the injected seam", () => { ); }, credentialsFor: () => ({ username: "u", accessKey: "k" }), - transport: transport as never, - startListener: (async () => ({ url: "x", token: "y", close: async () => {} })) as never, + streamTransport: streamed as never, }); const { payload } = await call(tools); expect(payload.ok).toBe(false); expect(payload.error).toMatch(/BROWSERSTACK_ACCESS_KEY/); - expect(transport).not.toHaveBeenCalled(); + // The point of the test: no token, so the stream is never opened at all — the + // refusal happens before anything reaches the network. + expect(streamed).not.toHaveBeenCalled(); }); it("does not need the user's access key to reach /agent", async () => { From a593ebc6ba653708cac9af26e9f20bf55e3351ce Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Thu, 27 Aug 2026 10:36:14 +0530 Subject: [PATCH 28/31] Stop claiming Atlas route-checks the approval description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It doesn't any more. Atlas removed the guard that replaced a route-shaped `description` with "(approval request withheld: …)" — it asked a human to approve a sentence they could not read — and CONTRACT v2 §3 was amended to match. No behaviour to change on this side: the description was always passed through verbatim, deliberately, because paraphrasing it would mean the human approves something other than what the model said. Only three comments were wrong, in `register.ts` and `relay.ts`, each asserting the string had already been checked upstream. The placeholder-framing test stays and is retitled: an Atlas predating the amendment still sends one, and it must not read as a bug once framed. Co-Authored-By: Claude Opus 5 (1M context) --- src/tools/ask-browserstack/register.ts | 13 ++++++++----- src/tools/ask-browserstack/relay.ts | 6 +++--- tests/tools/askBrowserstack.test.ts | 10 ++++++---- 3 files changed, 17 insertions(+), 12 deletions(-) diff --git a/src/tools/ask-browserstack/register.ts b/src/tools/ask-browserstack/register.ts index d9f8ed7..8e96928 100644 --- a/src/tools/ask-browserstack/register.ts +++ b/src/tools/ask-browserstack/register.ts @@ -165,11 +165,14 @@ async function relayOneAsk( mode: "form", // Framed with the PRODUCT and nothing else (v1.1 §G): a bare sentence with no // attribution is a worse prompt than a framed one, and `product` is all the - // callback carries — the route, method, path and op_key never reach this side by - // design. The description itself goes through VERBATIM: paraphrasing it would mean - // the human approves something other than what the model actually said. Atlas - // route-checks it first (v1.1 §A), so one that quoted an internal path arrives as a - // withheld-placeholder sentence, which reads correctly after the prefix. + // ask carries — the route, method, path and op_key never reach this side by design, + // and that is the whole privacy boundary (the ask is four named fields). The + // description goes through VERBATIM: paraphrasing it would mean the human approves + // something other than what the model actually said. Atlas no longer rewrites it + // either — it used to replace a route-shaped one with a placeholder, which asked a + // person to approve a sentence they could not read; CONTRACT v2 §3 was amended and + // that guard removed. An older Atlas can still send the placeholder, which is why + // the framing is tested against it. message: elicitationMessage(ask.product, ask.description), requestedSchema: { // NOTHING IS REQUESTED. The action IS the answer: `accept` already means the human diff --git a/src/tools/ask-browserstack/relay.ts b/src/tools/ask-browserstack/relay.ts index f453578..4054d45 100644 --- a/src/tools/ask-browserstack/relay.ts +++ b/src/tools/ask-browserstack/relay.ts @@ -225,9 +225,9 @@ export function atlasRelayVerdict( * `product` is the ONLY thing added — it is all §2 carries, and the route, method and path * never reach this side by design. The description itself is passed through untouched: * paraphrasing or truncating it would mean the human approves something other than what the - * model actually said, and Atlas has already route-checked it (v1.1 §A), so a description - * that quoted an internal path arrives as a withheld-placeholder sentence which reads - * perfectly well after this prefix. + * model actually said. Atlas sends it as the model wrote it (its route guard was removed — + * CONTRACT v2 §3, amended), so this prefix is the only thing in front of the model's own + * sentence. A placeholder can still arrive from an older Atlas and reads fine after it. */ export const PRODUCT_LABELS: Record = { tm: "Test Management", diff --git a/tests/tools/askBrowserstack.test.ts b/tests/tools/askBrowserstack.test.ts index 26e1e21..e46ecae 100644 --- a/tests/tools/askBrowserstack.test.ts +++ b/tests/tools/askBrowserstack.test.ts @@ -558,11 +558,13 @@ describe("the elicitation message — CONTRACT v1.1 §G", () => { ); }); - it("still reads as a prompt when Atlas withheld a route-shaped description", () => { - // v1.1 §A: a description that trips Atlas's route guard is replaced, not dropped, so - // what arrives is a sentence — and must not look like a bug once framed. + it("still reads as a prompt when an older Atlas withheld the description", () => { + // BACK-COMPAT ONLY NOW. Atlas used to replace a route-shaped description with this + // placeholder; that guard is gone (CONTRACT v2 §3, amended) because it asked a human to + // approve a sentence they could not read. A deployment predating the change still sends + // it, so the framing must not make it look like a bug. // - // THE REAL BYTES, from `collector.py:172-176` — `f"({kind} withheld: it referenced + // THE REAL BYTES, as `permissions.py` emitted them — `f"({kind} withheld: it referenced // internal API detail)"` with kind="approval request" — and observed on the wire in the // integration run. An invented placeholder is the one string in this feature a test can // assert on and be confidently wrong about. From 3e16fabec95d739e7973cb9bf6695939ffe558e9 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Thu, 27 Aug 2026 20:22:57 +0530 Subject: [PATCH 29/31] feat(ask): the hosted deployment may offer the relay, when it opts in MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `relayMode` refused unconditionally in REMOTE_MCP. That refusal has become factually wrong, and its own message said so: "this deployment has no way to put an approval prompt in front of you". It now does. if (appConfig.REMOTE_MCP && !allowRemoteRelay()) return "remote_mode"; MEASURED, not assumed. Against the hosted Streamable HTTP server with per-session servers (browserstack/remote-mcp-server#96): initialize issued an Mcp-Session-Id, the following tools/call was served by the SAME instance, and the run completed over HTTP. The thing that used to make this impossible was the host discarding its server after each POST, so an elicitation answer — which arrives on a SEPARATE POST — reached an instance that had never asked anything while the real one sat suspended. OFF BY DEFAULT, and that is not caution. It depends on a property of the HOST that this package cannot observe: * the host must keep one McpServer alive per session, and * it must pin a session to a pod. Sessions are per-process, so without affinity the answer POST can land on a replica that has never seen the session. Both were demonstrated the hard way: the first hosted attempt 404'd because it ran during a rollout, when two pods were briefly serving and the follow-up POST hit the one without the session. That failure is intermittent and reads like a client bug, which is exactly why it must not be a default. The flag only lifts the blanket refusal. `relayMode` still asks whether THIS client declared `elicitation`, so a client that did not still gets a read-only run — there is a test for that, because a flag that quietly forced asks onto clients unable to show them would be worse than the refusal it replaced. Also rewrote the rationale above `relayMode`. It asserted the relay was "a STDIO-ONLY FEATURE ... A config flag will not do it", which was correct when written and is now the opposite of true. It records what actually blocked it (a paused call cannot be moved between processes), why 841c6358 was right at the time, and what a hosted operator must have in place before turning this on. 481 tests pass, tsc clean. Co-Authored-By: Claude Opus 5 (1M context) --- src/tools/ask-browserstack/config.ts | 19 +++++++ src/tools/ask-browserstack/register.ts | 52 ++++++++++------- tests/tools/askBrowserstackE2E.test.ts | 78 ++++++++++++++++++++++++++ 3 files changed, 129 insertions(+), 20 deletions(-) diff --git a/src/tools/ask-browserstack/config.ts b/src/tools/ask-browserstack/config.ts index 6d94a71..8283ff8 100644 --- a/src/tools/ask-browserstack/config.ts +++ b/src/tools/ask-browserstack/config.ts @@ -31,6 +31,25 @@ export function isEnabled(): boolean { return (process.env.ASK_BROWSERSTACK_DISABLED || "").toLowerCase() !== "true"; } +/** + * May the relay be offered in the hosted (`REMOTE_MCP`) deployment? + * + * OFF BY DEFAULT, because it depends on something outside this package: the host has to + * keep one `McpServer` alive per session. Stateless hosts build a fresh server per POST, + * and an elicitation answer — which arrives as a SEPARATE POST — then reaches an instance + * that never asked anything, leaving the real one suspended until it times out. So this + * must stay opt-in per deployment rather than become a default that silently hangs. + * + * Turning it on does NOT force the relay on: `relayMode` still asks whether THIS client + * declared the `elicitation` capability, and a client that did not still gets a read-only + * run. This flag only removes the blanket refusal. + */ +export function allowRemoteRelay(): boolean { + return ( + (process.env.ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY || "").toLowerCase() === "true" + ); +} + /** * ============================================================================ * TEMPORARY STAGING DEFAULT — REPOINT BEFORE PRODUCTION USERS GET THIS TOOL diff --git a/src/tools/ask-browserstack/register.ts b/src/tools/ask-browserstack/register.ts index 8e96928..6c29a1e 100644 --- a/src/tools/ask-browserstack/register.ts +++ b/src/tools/ask-browserstack/register.ts @@ -44,6 +44,7 @@ import { AskError, ELICITATION_TIMEOUT_MS, agentUrl, + allowRemoteRelay, authTokenUrl, isEnabled, } from "./config.js"; @@ -227,29 +228,34 @@ async function relayOneAsk( } /** - * Decide whether to offer the approval channel at all — and in the hosted deployment, do not. + * Decide whether to offer the approval channel at all. * - * THE RELAY IS A STDIO-ONLY FEATURE, and that is a designed property rather than an accident. - * A1 fixed the two reachability problems A2 had — nothing binds a loopback port per tool - * call, and Atlas never dials back, so its SSRF allowlist is not in the path at all — but it - * cannot fix the one that actually blocks remote mode: + * STDIO ALWAYS. HOSTED ONLY WHEN ITS OPERATOR OPTS IN — and the reason is a property of + * the HOST, not of this tool. * - * Elicitation is a SERVER-INITIATED message, and the remote `/mcp` is stateless BY - * DELIBERATE DESIGN. Commit `841c6358` removed sessions because they broke behind two - * replicas — "Session not found on roughly half of every client's post-handshake calls" - * — and justified it precisely on the grounds that "we use neither server-initiated - * messages nor subscriptions/sampling". This feature is the exception that commit did - * not have to consider, and it needs the machinery that commit removed. + * Elicitation is a SERVER-INITIATED message whose answer arrives on a SEPARATE POST. A + * stateless host builds a fresh `McpServer` per POST, so that answer reaches an instance + * which never asked anything, while the one actually suspended on `await` waits out its + * timeout. Nothing in this package can fix that; what it holds is a live Promise resolver + * and a paused function in the host's heap, and a paused call cannot be moved. * - * So do not send `permission_relay` in remote mode: Atlas then runs read-only, which is a - * supported path that already works. Attempting the relay instead would buy a slow and - * confusing failure in place of a clear one — the run would stream asks nobody can be shown, - * and every one of them would expire into a deny 300s later. + * This is why `841c6358` was right to remove sessions from the hosted server on the + * grounds that "we use neither server-initiated messages nor subscriptions/sampling" — + * this feature is the exception that commit did not have to consider. * - * BEFORE RE-ENABLING THIS IN REMOTE MODE: the blocker is server-initiated messaging, not - * configuration, and A1 does NOT need a per-replica address any more (the decision is an - * ordinary inbound POST that Atlas routes to the owning pod itself). What is still missing is - * a way for a stateless replica to prompt the client. A config flag will not do it. + * MEASURED, not assumed: with the host keeping one server per session + * (browserstack/remote-mcp-server#96), a tool call and its elicitation answer were served + * by the same instance over hosted Streamable HTTP, and the relay completed. So the + * refusal below is now conditional rather than absolute. + * + * It stays OFF by default because it depends on a deployment property this package cannot + * observe. A hosted operator turns it on only once their host keeps sessions AND pins a + * session to a pod — sessions are per-process, so without affinity the answer POST can + * land on a replica that has never seen it. That failure is intermittent and reads like a + * client bug, which is exactly why it must not be the default. + * + * When refused, Atlas runs read-only — a supported path that already works — and + * `permission_relay.reason` says `remote_mode` so nobody mistakes it for a human's no. */ /** * CONTRACT v2 (A1) — drive one run over the stream. @@ -371,7 +377,13 @@ async function runStreamed( } export function relayMode(server: McpServer): RelayMode { - if (appConfig.REMOTE_MCP) return "remote_mode"; + // The hosted deployment refuses UNLESS its operator has opted in, because whether an + // elicitation can be answered there depends on the host keeping one server alive per + // session — see `allowRemoteRelay`. Verified working against the hosted Streamable + // HTTP server once it does (browserstack/remote-mcp-server#96). + if (appConfig.REMOTE_MCP && !allowRemoteRelay()) return "remote_mode"; + // The real gate either way: can THIS client be asked? A client that never declared + // `elicitation` gets a read-only run whatever the deployment. return server.server.getClientCapabilities()?.elicitation ? "offered" : "no_human"; diff --git a/tests/tools/askBrowserstackE2E.test.ts b/tests/tools/askBrowserstackE2E.test.ts index 5b596e8..8f3a649 100644 --- a/tests/tools/askBrowserstackE2E.test.ts +++ b/tests/tools/askBrowserstackE2E.test.ts @@ -1170,6 +1170,84 @@ describe("askBrowserstackAI, end to end through the server factory", () => { expect(payload.permission_relay.reason).toBe("remote_mode"); }); + it("DOES offer the relay in remote mode once the operator opts in", async () => { + // The refusal above is about the HOST, not this tool: a stateless host cannot + // deliver an elicitation answer to the instance waiting for it. Once the host keeps + // one server per session (verified against the hosted Streamable HTTP server, + // browserstack/remote-mcp-server#96) the refusal is wrong, so it is opt-in rather + // than absolute. + vi.resetModules(); + process.env.REMOTE_MCP = "true"; + process.env.ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY = "true"; + try { + const { addAskBrowserstackAITool } = await import( + "../../src/tools/ask-browserstack/register.js" + ); + const { McpServer: RemoteMcpServer } = await import( + "@modelcontextprotocol/sdk/server/mcp.js" + ); + const streamed = vi.fn(() => ({ + async *[Symbol.asyncIterator]() { + yield { event: "result", data: { status: "ok", answer: "" } }; + }, + })); + const remote = new RemoteMcpServer({ name: "t", version: "0" }); + vi.spyOn(remote.server, "getClientCapabilities") + .mockReturnValue({ elicitation: {} } as never); + const tools = addAskBrowserstackAITool(remote, { + agentUrl: () => "https://atlas.example/agent", + mintToken: async () => MINTED, + credentialsFor: () => ({ username: "ing_Xx", accessKey: "SECRET" }), + streamTransport: streamed as never, + }); + + const { payload } = await call(tools); + expect((streamed.mock.calls[0] as never as unknown[])[2]) + .toMatchObject({ permission_relay: { mode: "stream" } }); + expect(payload.permission_relay.reason).not.toBe("remote_mode"); + } finally { + delete process.env.ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY; + } + }); + + it("the opt-in does NOT force the relay onto a client that cannot be asked", async () => { + // The flag only lifts the blanket refusal. Whether a human can actually be reached + // is still per-client, and a client that never declared `elicitation` must still get + // a read-only run — otherwise the hosted server would stream asks nobody can see. + vi.resetModules(); + process.env.REMOTE_MCP = "true"; + process.env.ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY = "true"; + try { + const { addAskBrowserstackAITool } = await import( + "../../src/tools/ask-browserstack/register.js" + ); + const { McpServer: RemoteMcpServer } = await import( + "@modelcontextprotocol/sdk/server/mcp.js" + ); + const streamed = vi.fn(() => ({ + async *[Symbol.asyncIterator]() { + yield { event: "result", data: { status: "ok", answer: "" } }; + }, + })); + const remote = new RemoteMcpServer({ name: "t", version: "0" }); + vi.spyOn(remote.server, "getClientCapabilities") + .mockReturnValue({ roots: {} } as never); // no elicitation + const tools = addAskBrowserstackAITool(remote, { + agentUrl: () => "https://atlas.example/agent", + mintToken: async () => MINTED, + credentialsFor: () => ({ username: "ing_Xx", accessKey: "SECRET" }), + streamTransport: streamed as never, + }); + + const { payload } = await call(tools); + expect("permission_relay" in (streamed.mock.calls[0] as never as unknown[])[2]!) + .toBe(false); + expect(payload.permission_relay.reason).toBe("no_human"); + } finally { + delete process.env.ASK_BROWSERSTACK_ALLOW_REMOTE_RELAY; + } + }); + it("positive control: the same seam IS called when not in remote mode", async () => { // Without this, the assertion above would pass just as happily if the seam were // broken and nothing ever called it. From 49a30c9c6c3abc6b927db16eb34f704ef3b20d7b Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Thu, 27 Aug 2026 20:45:47 +0530 Subject: [PATCH 30/31] fix(ask): route each elicitation onto the tool call's own stream MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Over Streamable HTTP a server->client message is written to the stream of the request it RELATES to. `elicitInput` was called with no `relatedRequestId`, so the SDK fell back to the standalone SSE stream — and a host that answers `GET /mcp` with 405 has none. The message was then dropped in silence: const standaloneSse = this._streamMapping.get(this._standaloneSseStreamId); if (standaloneSse === undefined) { // Stream is disconnected - event is stored for replay, nothing more to do return; } The tool then waited out its 270s and Atlas's gate expired, so the run came back `decision: "deny", reason: "timeout"` — "refused: nobody answered in time". The person is blamed for not answering a question that was never put in front of them, which is the worst possible shape for a consent mechanism to fail in. MEASURED against the hosted Remote MCP server: the relay was offered (`permission_relay.used: true`) and the ask reached Atlas, but the client handled ZERO elicitations and the approval timed out. Fixed by threading the tool call's own request id through: the SDK hands it to the tool as `extra.requestId`, so the callback now takes `extra` and passes it to `runStreamed` -> `relayOneAsk` -> `elicitInput`. INVISIBLE ON STDIO, which is why this shipped: one pipe, nothing to route, so every local and stdio test passed while the hosted run timed out. Note the shared `call()` test helper passes `{}` as `extra` and therefore could never have caught it — the new test supplies a request id the way the SDK does, and it FAILS against the version without `relatedRequestId` (verified). Co-Authored-By: Claude Opus 5 (1M context) --- src/tools/ask-browserstack/register.ts | 23 +++++++++++++++++--- tests/tools/askBrowserstackE2E.test.ts | 30 ++++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 3 deletions(-) diff --git a/src/tools/ask-browserstack/register.ts b/src/tools/ask-browserstack/register.ts index 6c29a1e..210089b 100644 --- a/src/tools/ask-browserstack/register.ts +++ b/src/tools/ask-browserstack/register.ts @@ -33,6 +33,7 @@ import { ElicitResult, ErrorCode, McpError, + RequestId, } from "@modelcontextprotocol/sdk/types.js"; import { z } from "zod"; @@ -158,9 +159,21 @@ async function relayOneAsk( server: McpServer, ask: PermissionAsk, approvals: ApprovalRecord[], + relatedRequestId?: RequestId, ): Promise { let answer: ElicitResult; try { + // `relatedRequestId` IS LOAD-BEARING OVER HTTP, and its absence fails silently. + // Streamable HTTP routes a server->client message onto the stream of the request it + // relates to (`_requestToStreamMapping`). With no id the SDK falls back to the + // standalone SSE stream, and a host that answers GET /mcp with 405 has none — so the + // SDK drops the message with "Stream is disconnected", the tool waits out its 270s, + // and Atlas's gate expires into `reason: "timeout"`. The human is told they did not + // answer a question they were never shown. + // + // Measured exactly that way against the hosted server before this was threaded + // through. On stdio it is irrelevant — one pipe, nothing to route — which is why no + // local test could have caught it. answer = await server.server.elicitInput( { mode: "form", @@ -191,7 +204,7 @@ async function relayOneAsk( }, }, // The inner rung of CONTRACT §4's ladder, strictly shorter than Atlas's 300s gate. - { timeout: ELICITATION_TIMEOUT_MS }, + { timeout: ELICITATION_TIMEOUT_MS, relatedRequestId }, ); } catch (error) { if (isTimeout(error)) { @@ -284,6 +297,7 @@ async function runStreamed( approvals: ApprovalRecord[], mode: RelayMode, product: string, + relatedRequestId?: RequestId, ): Promise { let runId = ""; let result: unknown; @@ -333,7 +347,7 @@ async function runStreamed( // already recorded the approvals entry, so only the wire decision is missing. let decision: PermissionDecision; try { - decision = await relayOneAsk(server, ask, approvals); + decision = await relayOneAsk(server, ask, approvals, relatedRequestId); } catch (error) { logger.warn( "askBrowserstackAI: elicitation failed, denying explicitly: %s", @@ -433,7 +447,7 @@ export function addAskBrowserstackAITool( destructiveHint: false, title: "Ask BrowserStack AI", }, - async ({ product, query }): Promise => { + async ({ product, query }, extra): Promise => { track("askBrowserstackAI"); const approvals: ApprovalRecord[] = []; // Negotiated before anything else so the failure paths below report the mode they @@ -486,6 +500,9 @@ export function addAskBrowserstackAITool( approvals, mode, product, + // The tool call's own id, so each elicitation is routed onto THIS request's + // stream. Over Streamable HTTP there is nowhere else for it to go. + extra?.requestId, ), ); } catch (error) { diff --git a/tests/tools/askBrowserstackE2E.test.ts b/tests/tools/askBrowserstackE2E.test.ts index 8f3a649..d53f5a4 100644 --- a/tests/tools/askBrowserstackE2E.test.ts +++ b/tests/tools/askBrowserstackE2E.test.ts @@ -444,6 +444,36 @@ describe("askBrowserstackAI, end to end through the server factory", () => { expect(payload.approvals[0].reason).toBe("timeout"); }); + it("routes each elicitation onto the tool call's own stream (relatedRequestId)", async () => { + // THE BUG THIS EXISTS FOR, found only against the hosted server. + // + // Streamable HTTP sends a server->client message on the stream of the request it + // relates to. With no `relatedRequestId` the SDK falls back to the standalone SSE + // stream — and a host answering GET /mcp with 405 has none, so the elicitation is + // DROPPED SILENTLY ("Stream is disconnected"). The tool then waits out its 270s and + // Atlas's gate expires into `reason: "timeout"`: the person is told they failed to + // answer a question they were never shown. + // + // Invisible on stdio, which has one pipe and nothing to route — which is why every + // local test passed while the hosted run timed out. Note the shared `call()` helper + // passes `{}` as `extra`, so it could never have caught this; this test supplies a + // request id the way the SDK does. + const server = await buildServer(); + const elicit = fakeClient(server.getInstance(), { elicitation: {} }, [ + { action: "accept" }, + ]); + atlas({ asks: [{ perm_id: PERM_A, description: "Archive the plan." }] }); + + await server.getTools().askBrowserstackAI.handler( + { product: "tm", query: "make a folder" }, + { requestId: 4242 } as never, + ); + + expect(elicit).toHaveBeenCalledTimes(1); + const options = elicit.mock.calls[0][1]; + expect(options).toMatchObject({ relatedRequestId: 4242 }); + }); + it("fails closed with a non-200 when the relay breaks in an unexpected way", async () => { const server = await buildServer(); fakeClient(server.getInstance(), { elicitation: {} }, [new Error("client went away")]); From fa11a3a9de85d686cb2a790b00268016bb0bfad7 Mon Sep 17 00:00:00 2001 From: Shreyas Sarve Date: Thu, 27 Aug 2026 23:13:15 +0530 Subject: [PATCH 31/31] askBrowserstackAI: point the compiled defaults at production DEFAULT_ATLAS_URL -> https://workflows.browserstack.com DEFAULT_AUTH_TOKEN_URL -> https://auth.browserstack.com/oauth2/v2/token These were deliberate staging placeholders ("for now lets hardcode the base_url to staging only then we will point this to prod url later"); this is that step. Both hosts were verified rather than guessed - workflows.browserstack.com answers /api/profiles with 401 {"detail":"authentication required"}, byte-identical to staging Atlas. Note what this removes: an install with no env vars used to fail SAFE onto staging, where it could not touch production data. It now reaches real customer data by default, so every non-production deployment must set ASK_BROWSERSTACK_ATLAS_URL / ASK_BROWSERSTACK_AUTH_TOKEN_URL explicitly. The staging hosts are recorded in the comment block for exactly that purpose, and the resolved host is still logged at info on first use naming env-vs-default. Tests assert the literals so a future repoint stays deliberate; updated with the constants. Full suite green (482 tests). Co-Authored-By: Claude Opus 5 (1M context) --- src/tools/ask-browserstack/config.ts | 43 ++++++++++++++------------ tests/tools/askBrowserstack.test.ts | 16 +++++----- tests/tools/askBrowserstackE2E.test.ts | 8 ++--- 3 files changed, 35 insertions(+), 32 deletions(-) diff --git a/src/tools/ask-browserstack/config.ts b/src/tools/ask-browserstack/config.ts index 8283ff8..a787353 100644 --- a/src/tools/ask-browserstack/config.ts +++ b/src/tools/ask-browserstack/config.ts @@ -52,36 +52,39 @@ export function allowRemoteRelay(): boolean { /** * ============================================================================ - * TEMPORARY STAGING DEFAULT — REPOINT BEFORE PRODUCTION USERS GET THIS TOOL + * PRODUCTION DEFAULTS * ============================================================================ * - * These hosts are STAGING. They are hardcoded on purpose, as an explicit interim step: - * "for now lets hardcode the base_url to staging only then we will point this to prod url - * later." This is a placeholder, not the end state. + * These hosts are PRODUCTION. They replace the interim staging placeholders that this + * package shipped with while the relay was being built ("for now lets hardcode the + * base_url to staging only then we will point this to prod url later") — that step is + * now done. * - * PRODUCTION IS `https://workflows.browserstack.com` — verified, not guessed: its - * `/api/profiles` answers `401 {"detail":"authentication required"}`, byte-identical to - * staging Atlas. (`/agent` 404s there today only because prod runs an image without the - * delegation route yet, and `/fe` 404s because prod is API-only by design.) The production - * auth endpoint is `https://auth.browserstack.com/oauth2/v2/token`. + * `https://workflows.browserstack.com` was verified, not guessed: its `/api/profiles` + * answers `401 {"detail":"authentication required"}`, byte-identical to staging Atlas. + * The production auth endpoint is `https://auth.browserstack.com/oauth2/v2/token`. * * WHY THIS MATTERS: this package publishes to npm as `@browserstack/mcp-server`, so an - * install with no environment variables set talks to STAGING. That is the safer direction — - * it cannot touch production data — but it is still wrong for a production deployment, which - * would silently read and write the wrong environment's data. The resolved host is therefore - * logged at info on first use, naming whether it came from the env var or from here, so a - * deployment pointing at the wrong Atlas is visible in a log line rather than inferred later - * from confusing data. + * install with no environment variables set now talks to PRODUCTION. That is correct for + * a production deployment, but it removes the old safety property — a misconfigured or + * test deployment that forgets `ASK_BROWSERSTACK_ATLAS_URL` no longer fails safe onto + * staging, it reads and writes REAL customer data. Non-production deployments MUST set + * that variable explicitly. The resolved host is logged at info on first use, naming + * whether it came from the env var or from here, so a deployment pointing at the wrong + * Atlas is visible in a log line rather than inferred later from confusing data. * - * BEFORE SHIPPING TO PRODUCTION USERS: change these two constants, and change the tests that - * assert them — they assert the literals precisely so that repointing has to be deliberate + * Staging hosts, for anyone setting the override: + * ASK_BROWSERSTACK_ATLAS_URL = https://ai-platform-service.bsstag.com + * ASK_BROWSERSTACK_AUTH_TOKEN_URL = https://auth-preprod.bsstag.com/oauth2/v2/token + * + * The tests assert these literals precisely so that repointing has to be deliberate * rather than something that slips through. * - * grep: TEMPORARY-STAGING-DEFAULT + * grep: DEFAULT-PROD-HOSTS */ -export const DEFAULT_ATLAS_URL = "https://ai-platform-service.bsstag.com"; +export const DEFAULT_ATLAS_URL = "https://workflows.browserstack.com"; export const DEFAULT_AUTH_TOKEN_URL = - "https://auth-preprod.bsstag.com/oauth2/v2/token"; + "https://auth.browserstack.com/oauth2/v2/token"; /** An operator's override may carry a trailing slash; the constants above do not. */ function trimUrl(value: string): string { diff --git a/tests/tools/askBrowserstack.test.ts b/tests/tools/askBrowserstack.test.ts index e46ecae..746c33b 100644 --- a/tests/tools/askBrowserstack.test.ts +++ b/tests/tools/askBrowserstack.test.ts @@ -1093,12 +1093,12 @@ describe("host resolution — one hardcoded staging default, one override", () = it("with NO env vars, resolves the staging pair — exact literals", () => { // Asserted literally so repointing to production has to be a deliberate test change - // rather than something that slips through. See TEMPORARY-STAGING-DEFAULT. - expect(atlasBaseUrl()).toBe("https://ai-platform-service.bsstag.com"); - expect(agentUrl()).toBe("https://ai-platform-service.bsstag.com/agent"); - expect(authTokenUrl()).toBe("https://auth-preprod.bsstag.com/oauth2/v2/token"); - expect(DEFAULT_ATLAS_URL).toBe("https://ai-platform-service.bsstag.com"); - expect(DEFAULT_AUTH_TOKEN_URL).toBe("https://auth-preprod.bsstag.com/oauth2/v2/token"); + // rather than something that slips through. See DEFAULT-PROD-HOSTS. + expect(atlasBaseUrl()).toBe("https://workflows.browserstack.com"); + expect(agentUrl()).toBe("https://workflows.browserstack.com/agent"); + expect(authTokenUrl()).toBe("https://auth.browserstack.com/oauth2/v2/token"); + expect(DEFAULT_ATLAS_URL).toBe("https://workflows.browserstack.com"); + expect(DEFAULT_AUTH_TOKEN_URL).toBe("https://auth.browserstack.com/oauth2/v2/token"); }); it("never refuses for want of configuration — an install needs no env var", () => { @@ -1164,9 +1164,9 @@ describe("the resolved host is announced, so a wrong deployment is visible", () // The logger is printf-style, so the captured args arrive alongside the format string. const everything = lines.join("\n"); expect(everything).toContain("source:"); - expect(everything).toContain("Atlas https://ai-platform-service.bsstag.com default"); + expect(everything).toContain("Atlas https://workflows.browserstack.com default"); expect(everything).toContain( - "auth token endpoint https://auth-preprod.bsstag.com/oauth2/v2/token default", + "auth token endpoint https://auth.browserstack.com/oauth2/v2/token default", ); }); diff --git a/tests/tools/askBrowserstackE2E.test.ts b/tests/tools/askBrowserstackE2E.test.ts index d53f5a4..8631b97 100644 --- a/tests/tools/askBrowserstackE2E.test.ts +++ b/tests/tools/askBrowserstackE2E.test.ts @@ -828,8 +828,8 @@ describe("askBrowserstackAI, end to end through the server factory", () => { const { payload } = await call(server.getTools()); expect(payload.status).toBe("ok"); expect(seen).toEqual([ - "https://auth-preprod.bsstag.com/oauth2/v2/token", - "https://ai-platform-service.bsstag.com/agent", + "https://auth.browserstack.com/oauth2/v2/token", + "https://workflows.browserstack.com/agent", ]); }); @@ -1360,8 +1360,8 @@ describe("askBrowserstackAI, end to end through the server factory", () => { const { result } = await call(server.getTools()); expect(result.isError).toBeUndefined(); - // TEMPORARY-STAGING-DEFAULT: asserted literally so repointing must be deliberate. - expect(seen).toContain("https://ai-platform-service.bsstag.com/agent"); + // DEFAULT-PROD-HOSTS: asserted literally so repointing must be deliberate. + expect(seen).toContain("https://workflows.browserstack.com/agent"); }); });